lmjtfy.git / apps / lmjtfy / src / view / tests.rs
tests.rsannotatedtests.rssource571 lines · 26.1 KB · raw
1use ask::{Judged, Outcome, Sent};
2use jev_protocol::{ChoiceAnswer, ScoreAnswer, Usage};
3use llm::{Draft, Opt, ToolCall};
4
5use super::*;

An answer as it was kept before its numbers were.

8fn said(headline: &str) -> Answer {
9    Answer { headline: headline.into(), detail: Detail::Unknown }
10}

The few words of each answer, as the feed prints them.

13fn headlines(shown: &View) -> Vec<String> {
14    shown.said().into_iter().map(|answer| answer.headline).collect()
15}

An answer to one question, and whether it was sent now or kept.

18type Reply = (Judged, Sent);
20fn answered(judged: Judged) -> Option<Reply> {
21    answered_and(judged, Sent::Now)
22}
23
24fn answered_and(judged: Judged, sent: Sent) -> Option<Reply> {
25    Some((judged, sent))
26}

A question that went to Jev, answered or still waiting.

29struct Asked(&'static str, Option<Reply>);
31fn jev(kind: &'static str, answer: Option<Reply>) -> Asked {
32    Asked(kind, answer)
33}

A tool call the LLM made, and the question it became, if it was sent.

36fn tool(draft: Result<Draft, String>, asked: Option<Asked>) -> (ToolCall, Option<Asked>) {
37    let name = draft.as_ref().map_or("jev_noul", Draft::tool).to_owned();
38    (ToolCall { name, arguments: r#"{"instructions":"q"}"#.into(), draft }, asked)
39}
41fn outcome(judged: Vec<Judged>, sent: Sent) -> Outcome {
42    Outcome::Answered {
43        judged,
44        sent,
45        body: r#"{"model":"jev-1.13.0"}"#.into(),
46        request_id: Some("req_1".into()),
47        attempts: 1,
48        usage: Usage { input_tokens: 300, output_tokens: 20 },
49        took: Duration::from_millis(150),
50    }
51}
52
53fn call(id: &'static str, parts: Vec<(String, &'static str)>, outcome: Option<Outcome>) -> JevCall {
54    JevCall { id, parts, request: r#"{"model":"jev-1.13.0"}"#.into(), outcome, kept: None }
55}

A view after the facts were asked for: the tools, and the one request that carries every question that was sent. It is answered only when every question in it is.

60fn view(tools: Vec<(ToolCall, Option<Asked>)>, ending: Option<Ending>) -> View {
61    let mut shown = View::new("what is the best editor".into());
62    shown.facts = Some(call("facts", vec![("answerable".into(), "noul")], None));
63    let mut parts = Vec::new();
64    let mut answers = Vec::new();
65    for (index, (tool, asked)) in tools.into_iter().enumerate() {
66        let id = format!("q{}", index + 1);
67        if let Some(Asked(kind, answer)) = asked {
68            parts.push((id.clone(), kind));
69            answers.push(answer);
70        }
71        shown.tools.push(Tool { id, call: tool, refused: None });
72    }
73    if !parts.is_empty() {
74        let sent = answers.iter().flatten().map(|(_, sent)| *sent).next().unwrap_or(Sent::Now);
75        let judged: Option<Vec<Judged>> = answers.into_iter().map(|answer| answer.map(|(judged, _)| judged)).collect();
76        let answered = judged.is_some();
77        shown.judging = Some(call("answers", parts, judged.map(|judged| outcome(judged, sent))));
78        shown.known.learn(Fact::Judged, rules::Value::Bool(answered));
79    }
80    shown.ending = ending;
81    shown
82}
84#[test]
85fn the_input_is_escaped() {
86    let mut shown = View::new("<script>alert(1)</script>".into());
87    shown.ending = Some(Ending::Offline);
88    let html = transcript(&shown).into_string();
89    assert!(!html.contains("<script>"));
90    assert!(html.contains("&lt;script&gt;"));
91}
92
93#[test]
94fn the_share_link_round_trips_a_question() {
95    assert_eq!(url_encode("is 2+2 = 4?"), "is+2%2B2+%3D+4%3F");
96}
97
98#[test]
99fn a_pending_call_shows_its_request_and_no_response() {
100    let html = transcript(&view(Vec::new(), None)).into_string();
101    assert!(html.contains("waiting for Jev"));
102    assert!(html.contains(r#"<span class="k">&quot;model&quot;</span>: <span class="s">&quot;jev-1.13.0&quot;</span>"#));
103    assert!(!html.contains(">response</span>"));
104    assert!(html.contains("Step 1"));
105}
106
107#[test]
108fn a_choice_is_drawn_most_likely_first_with_the_winner_marked() {
109    let draft = Draft::Choice {
110        instructions: "Which editor is best?".into(),
111        options: vec![
112            Opt { label: "Vim".into(), description: "modal".into() },
113            Opt { label: "Emacs".into(), description: "lisp".into() },
114        ],
115    };
116    let answer = ChoiceAnswer {
117        choice: "Emacs".into(),
118        confidence: 0.8,
119        probabilities: vec![("Vim".into(), 0.25), ("Emacs".into(), 0.75)],
120    };
121    let shown = view(vec![tool(Ok(draft), Some(jev("choice", answered(Judged::Choice(answer)))))], Some(Ending::Answered));
122    let html = transcript(&shown).into_string();
123    assert!(html.contains("Which editor is best?"));
124    assert!(html.contains(r#"<p class="answer pick">Emacs.</p>"#));
125    assert!(html.find("left: 75.0%").unwrap() < html.find("left: 25.0%").unwrap());
126    assert!(html.contains(r#"<div class="bar won"><span class="label">Emacs</span>"#));
127    assert!(html.contains("Was that so hard?"));
128}
129
130#[test]
131fn a_score_is_drawn_lowest_first_with_the_nearest_level_marked() {
132    let draft = Draft::Score { instructions: "How good?".into(), levels: vec!["bad".into(), "fine".into(), "good".into()] };
133    let answer = ScoreAnswer {
134        score: 1.6,
135        confidence: 0.6,
136        probabilities: vec![0.1, 0.2, 0.7],
137        legend: vec!["bad".into(), "fine".into(), "good".into()],
138    };
139    let shown = view(vec![tool(Ok(draft), Some(jev("score", answered(Judged::Score(answer)))))], Some(Ending::Answered));
140    let html = transcript(&shown).into_string();
141    assert!(html.contains(r#"<p class="answer pick">good.<span class="of"> 1.6 / 2</span></p>"#));
142    assert!(html.contains(r#"<div class="bar won"><span class="label">2 · good</span>"#));
143    assert!(html.find("0 · bad").unwrap() < html.find("2 · good").unwrap());
144}
145
146#[test]
147fn a_call_the_llm_got_wrong_is_shown_and_not_sent() {
148    let shown = view(vec![tool(Err("missing field `yes_means`".into()), None)], Some(Ending::NoQuestion));
149    let html = transcript(&shown).into_string();
150    assert!(html.contains("Not sent to Jev: missing field `yes_means`"));
151    assert!(html.contains("Jev wasn&#39;t asked anything.") || html.contains("Jev wasn't asked anything."));
152    assert!(!html.contains("Was that so hard?"));
153}

The printed JSON, with the markup taken back out, is the same JSON.

156#[test]
157fn pretty_printing_changes_only_whitespace() {
158    let raw = r#"{"model":"jev-1.13.0","questions":{"gate":{"type":"noul","criteria":{"true":"a \"quoted\" <b>","false":""}}},"state":{"input":"é?","n":[0.97,1,true,null,{},[]]}}"#;
159    let html = json(raw).into_string();
160    assert!(html.contains("\n  <span class=\"k\">&quot;model&quot;</span>"));
161    let mut text = String::new();
162    let mut in_tag = false;
163    for ch in html.chars() {
164        match ch {
165            '<' => in_tag = true,
166            '>' => in_tag = false,
167            _ if !in_tag => text.push(ch),
168            _ => {}
169        }
170    }
171    let text = text.replace("&quot;", "\"").replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&");
172    let shown: Value = serde_json::from_str(&text).unwrap();
173    assert_eq!(serde_json::to_string(&shown).unwrap(), raw);
174}
176#[test]
177fn text_that_is_not_json_is_shown_as_it_is() {
178    assert_eq!(json("upstream <down>").into_string(), "<pre>upstream &lt;down&gt;</pre>");
179}
180
181#[test]
182fn the_live_line_says_whether_jev_can_judge_it_and_what_kind_it_is() {
183    let line = |answerable, kind| live(Some(Glance { answerable, kind })).into_string();
184    assert!(line(0.97, Some(Kind::Choice)).contains("Jev can judge this · a pick"));
185    assert!(line(0.97, None).contains("Jev can judge this"));
186    let no = line(0.05, Some(Kind::Noul));
187    assert!(no.contains("can't judge this yet") || no.contains("can&#39;t judge this yet"));
188    assert!(!no.contains("a yes or no"));
189    assert_eq!(live(None).into_string(), r#"<p class="live" id="live" aria-live="polite"></p>"#);
190}
191
192#[test]
193fn an_icon_draws_one_square_per_inked_cell() {
194    let inked: usize = icons::JEV.iter().map(|row| row.matches('#').count()).sum();
195    assert_eq!(icon(&icons::JEV, "").into_string().matches("<rect").count(), inked);
196    for rows in [icons::ASK, icons::JEV, icons::LLM, icons::TOOL] {
197        assert!(rows.iter().all(|row| row.len() == 12));
198    }
199}
200
201#[test]
202fn the_budget_window_shows_what_is_left_and_whose_count_it_is() {
203    let status = budget::Status { neurons: 1068.2, counted: budget::Counted::Account, jev_dollars: 0.0123 };
204    let html = budget(Some(Shared { status, neurons_per_day: 10_000.0, jev_dollars_per_day: 0.25 })).into_string();
205    assert!(html.contains("8,932 of 10,000"), "{html}");
206    assert!(html.contains("$0.2377 of $0.25"));
207    assert!(html.contains("left: 89.3%"));
208    assert!(html.contains("Cloudflare's own count") || html.contains("Cloudflare&#39;s own count"));
209    let own = budget::Status { counted: budget::Counted::Own, ..status };
210    let html = budget(Some(Shared { status: own, neurons_per_day: 10_000.0, jev_dollars_per_day: 0.25 })).into_string();
211    assert!(html.contains("this site") && html.contains("own count"));
212    assert!(budget(None).into_string().contains("could not be read"));
213}
214
215#[test]
216fn an_overspent_budget_shows_nothing_left_not_a_negative() {
217    let status = budget::Status { neurons: 10_400.0, counted: budget::Counted::Account, jev_dollars: 0.3 };
218    let html = budget(Some(Shared { status, neurons_per_day: 10_000.0, jev_dollars_per_day: 0.25 })).into_string();
219    assert!(html.contains("0 of 10,000"));
220    assert!(html.contains("$0.0000 of $0.25"));
221    assert!(html.contains("left: 0.0%"));
222}
223
224#[test]
225fn numbers_are_grouped_in_thousands() {
226    assert_eq!(grouped(0.4), "0");
227    assert_eq!(grouped(999.0), "999");
228    assert_eq!(grouped(1000.0), "1,000");
229    assert_eq!(grouped(1_234_567.0), "1,234,567");
230}
231
232#[test]
233fn an_unsure_noul_ends_in_a_question_mark() {
234    let draft = || Draft::Noul { instructions: "Is AI a bit stupid?".into(), yes_means: "y".into(), no_means: "n".into() };
235    for (p_yes, word) in [(0.55, "Yes?"), (0.45, "No?"), (0.7, "Yes."), (0.3, "No."), (0.97, "Yes."), (0.02, "No.")] {
236        let shown = view(vec![tool(Ok(draft()), Some(jev("noul", answered(Judged::Noul(p_yes)))))], Some(Ending::Answered));
237        let html = transcript(&shown).into_string();
238        assert!(html.contains(&format!(r#"<p class="answer">{word}</p>"#)), "{p_yes}: {html}");
239    }
240    // The gate's refusal is a Noul too.
241    let refused = view(Vec::new(), Some(Ending::NotAQuestion { p_yes: 0.4 }));
242    assert!(transcript(&refused).into_string().contains(r#"<p class="answer">No?</p>"#));
243}
244
245#[test]
246fn a_kept_response_says_it_was_not_sent_and_when_it_was_answered() {
247    let draft = Draft::Noul { instructions: "Is it?".into(), yes_means: "y".into(), no_means: "n".into() };
248    let kept = answered_and(Judged::Noul(0.9), Sent::Before { at_ms: 1_790_971_860_000.0 });
249    let mut shown = view(vec![tool(Ok(draft), Some(jev("noul", kept)))], Some(Ending::Answered));
250    shown.llm = Some(LlmCall {
251        model: "@cf/x",
252        request: "{}".into(),
253        outcome: Some(LlmOutcome {
254            body: "{}".into(),
255            neurons: 16.0,
256            took: Duration::from_millis(3000),
257            dropped: 0,
258            sent: Sent::Before { at_ms: 1_790_971_860_000.0 },
259        }),
260        kept: Some(Versions { id: "aaaaaaaaaaaa".into(), version: 2, versions: 3 }),
261    });
262    let html = transcript(&shown).into_string();
263    // An old answer of three: a step each way, and ↻.
264    assert!(html.contains("answer 2 of 3"), "{html}");
265    assert!(html.contains("aaaaaaaaaaaa:1'") && html.contains("aaaaaaaaaaaa:3'") && html.contains("aaaaaaaaaaaa:fresh'"), "{html}");
266    assert_eq!(shown.pinned(), "aaaaaaaaaaaa:2");
267    assert_eq!(html.matches("not sent · answered 2026-10-02 20:11 UTC").count(), 2, "{html}");
268    // The answer and the response body are shown as for a call just made.
269    assert!(html.contains(r#"<p class="answer">Yes.</p>"#));
270    assert!(html.contains(">response</span>"));
271    assert!(!html.contains("3000 ms"));
272}
273
274#[test]
275fn the_feed_links_each_question_and_escapes_it() {
276    let entries = [
277        Entry { input: "is <b>this</b> ok?".into(), answers: vec![said("No?")], asked_ms: 1_790_971_860_000.0, times: 1 },
278        Entry { input: "best editor".into(), answers: vec![said("Vim."), said("Yes.")], asked_ms: 0.0, times: 12 },
279    ];
280    let home = Home { lately: entries.to_vec(), most: vec![entries[1].clone()], stats: Stats::default() };
281    let html = feed(Some(&home)).into_string();
282    assert!(html.contains("most asked"));
283    assert_eq!(html.matches("×12").count(), 2);
284    assert!(!html.contains("×1<"));
285    assert!(html.contains(r#"href="/?q=is+%3Cb%3Ethis%3C%2Fb%3E+ok%3F""#), "{html}");
286    assert!(html.contains("is &lt;b&gt;this&lt;/b&gt; ok?"));
287    assert!(!html.contains("<b>"));
288    assert!(html.contains(r#"<span class="said-s">Vim. Yes.<span class="times"> ×12</span></span>"#), "{html}");
289    assert!(html.find("is &lt;b&gt;").unwrap() < html.find("best editor").unwrap());
290}
291
292#[test]
293fn an_empty_or_unread_feed_is_still_a_place_to_put_the_first_line() {
294    for empty in [feed(None), feed(Some(&Home::default()))] {
295        let html = empty.into_string();
296        assert!(html.contains(r#"<ol class="feed" id="lately"><li id="more" hidden></li></ol>"#), "{html}");
297        assert!(!html.contains("class=\"entry\""), "the style sheet hides a feed with no line: {html}");
298    }
299}
300
301fn asked(n: usize) -> Entry {
302    Entry { input: format!("question {n}?"), answers: vec![said("Yes.")], asked_ms: 1_790_000_000_000.0 - n as f64, times: 1 }
303}
304
305#[test]
306fn a_full_page_of_the_feed_asks_for_the_page_after_its_last_line_once() {
307    let lately: Vec<Entry> = (0..::archive::FEED as usize).map(asked).collect();
308    let html = feed(Some(&Home { lately, ..Home::default() })).into_string();
309    let last = ::archive::FEED - 1;
310    assert!(
311        html.contains(&format!(r#"<li id="more" data-on-intersect__once="@get('/feed?ms={}&amp;q=question+{last}%3F')">"#, 1_790_000_000_000.0 - f64::from(last))),
312        "{html}"
313    );
314}
315
316#[test]
317fn a_short_page_is_the_end_of_the_feed_and_asks_nothing() {
318    let (lines, end) = older(&[asked(1), asked(2)]);
319    assert_eq!(lines.into_string().matches("<li class=\"entry\"").count(), 2);
320    assert_eq!(end.into_string(), r#"<li id="more" hidden></li>"#);
321    assert_eq!(older(&[]).1.into_string(), r#"<li id="more" hidden></li>"#);
322}
323
324#[test]
325fn a_question_has_one_id_in_asked_lately_and_none_in_most_asked() {
326    let entry = asked(7);
327    let home = Home { lately: vec![entry.clone()], most: vec![entry.clone()], stats: Stats::default() };
328    let html = feed(Some(&home)).into_string();
329    let top = lately_top(&entry).into_string();
330    let id = top.split("id=\"").nth(1).and_then(|rest| rest.split('"').next()).expect("an id");
331    assert!(id.starts_with("q-") && id.len() == 18, "{id}");
332    assert_eq!(html.matches(&format!("id=\"{id}\"")).count(), 1, "{html}");
333    assert!(top.starts_with(r#"<ol hidden data-prepend="lately">"#), "{top}");
334    assert_ne!(lately_top(&asked(8)).into_string(), top);
335}
336
337#[test]
338fn the_feed_gets_every_answer_in_a_few_words() {
339    let noul = Draft::Noul { instructions: "Is it?".into(), yes_means: "y".into(), no_means: "n".into() };
340    let pick = ChoiceAnswer { choice: "Vim".into(), confidence: 0.9, probabilities: vec![("Vim".into(), 0.9), ("Emacs".into(), 0.1)] };
341    let shown = view(
342        vec![
343            tool(Ok(noul.clone()), Some(jev("noul", answered(Judged::Noul(0.55))))),
344            tool(Ok(noul.clone()), Some(jev("choice", answered(Judged::Choice(pick))))),
345            tool(Err("not a question".into()), None),
346        ],
347        Some(Ending::Answered),
348    );
349    assert_eq!(headlines(&shown), ["Yes?", "Vim."]);
350}

Marks the input as one answerable question read as kind, with Jev's own answers in hand.

354fn read_as(shown: &mut View, kinds: &[Kind]) {
355    use rules::Value;
356    shown.known.learn(Fact::Answerable, Value::Bool(true));
357    shown.known.learn(Fact::Several, Value::Bool(false));
358    shown.known.learn(Fact::Scale, Value::Bool(false));
359    shown.known.learn(Fact::Yes, Value::Given);
360    shown.known.learn(Fact::Degree, Value::Given);
361    shown.known.learn(Fact::Chance, Value::Given);
362    for kind in Kind::ALL {
363        shown.known.learn(Fact::Reads(kind), Value::Bool(kinds.contains(&kind)));
364    }
365}
367fn facts(answers: Vec<(Fact, Judged)>) -> JevCall {
368    let kind = |judged: &Judged| match judged {
369        Judged::Noul(_) => "noul",
370        Judged::Choice(_) => "choice",
371        Judged::Score(_) => "score",
372    };
373    let parts = answers.iter().map(|(fact, judged)| (ask::question_id(*fact).to_owned(), kind(judged))).collect();
374    call("facts", parts, Some(outcome(answers.into_iter().map(|(_, judged)| judged).collect(), Sent::Now)))
375}
376
377#[test]
378fn a_direct_answer_is_the_input_answered_by_jev_with_no_llm() {
379    let mut shown = View::new("is water wet?".into());
380    shown.facts = Some(facts(vec![(Fact::Answerable, Judged::Noul(0.95)), (Fact::Yes, Judged::Noul(0.88))]));
381    read_as(&mut shown, &[Kind::Noul]);
382    shown.ending = Some(Ending::Answered);
383    let html = transcript(&shown).into_string();
384    assert!(html.contains(r#"<p class="asked">is water wet?</p>"#), "{html}");
385    assert!(html.contains(r#"<p class="answer">Yes.</p>"#));
386    assert!(html.contains("p(yes) = 0.88"));
387    assert!(html.contains("Was that so hard?"));
388    assert!(html.contains("Send it to them:"));
389    // The diagram names the LLM; no call to it is shown.
390    assert!(!html.contains("neurons") && !html.contains("the LLM's tool call"));
391    assert_eq!(headlines(&shown), ["Yes."]);
392}
393
394#[test]
395fn a_request_says_how_many_questions_it_carries() {
396    let mut shown = View::new("is water wet?".into());
397    shown.facts = Some(facts(vec![(Fact::Answerable, Judged::Noul(0.95)), (Fact::Yes, Judged::Noul(0.88))]));
398    let html = transcript(&shown).into_string();
399    assert!(html.contains("jev · facts · 2 questions"), "{html}");
400}
401
402#[test]
403fn the_rules_are_drawn_with_what_is_known_marked() {
404    let mut shown = View::new("is water wet?".into());
405    shown.facts = Some(facts(vec![(Fact::Answerable, Judged::Noul(0.1))]));
406    shown.known.learn(Fact::Answerable, rules::Value::Bool(false));
407    shown.fired = vec![0];
408    shown.ending = Some(Ending::NotAQuestion { p_yes: 0.1 });
409    let html = transcript(&shown).into_string();
410    assert!(html.contains("rules · rete"));
411    assert!(html.contains(r#"<span class="meta">1 refuse</span>"#), "{html}");
412    assert!(!shown.answered());
413    // Jev's own number, not the yes or no it was read as.
414    assert!(html.contains(">0.10</text>"));
415}
416
417#[test]
418fn a_tool_call_says_which_question_it_became() {
419    let noul = Draft::Noul { instructions: "Is it?".into(), yes_means: "y".into(), no_means: "n".into() };
420    let sent = tool(Ok(noul), Some(jev("noul", answered(Judged::Noul(0.9)))));
421    let shown = view(vec![sent, tool(Err("bad".into()), None)], Some(Ending::Answered));
422    let html = transcript(&shown).into_string();
423    assert!(html.contains("sent to Jev as q1"));
424    assert!(html.contains("Not sent to Jev: bad"));
425    assert!(html.contains("jev · answers · 1 question<"), "{html}");
426}
427
428#[test]
429fn a_link_unfurls_as_the_question_and_what_jev_said() {
430    let said = Entry { input: "is AI a bit stupid?".into(), answers: vec![Answer { headline: "No?".into(), detail: Detail::Noul { p_yes: 0.36 } }], asked_ms: 0.0, times: 1 };
431    let html = page("is AI a bit stupid?", Some(&said), "https://x.test", None, None).into_string();
432    assert!(html.contains(r#"<meta property="og:title" content="Q: is AI a bit stupid?">"#), "{html}");
433    assert!(html.contains(r#"<meta property="og:description" content="Jev: No? · Noul · p(yes) = 0.36">"#), "{html}");
434    assert!(html.contains("<title>Q: is AI a bit stupid?</title>"));
435    // The picture's address carries the question and a mark of the answer.
436    let picture = format!("https://x.test/card.png?q=is+AI+a+bit+stupid%3F&amp;a={}", mark("No? · Noul · p(yes) = 0.36"));
437    assert!(html.contains(&format!(r#"<meta property="og:image" content="{picture}">"#)), "{html}");
438    assert!(html.contains(r#"<meta property="og:image:width" content="600">"#));
439    assert_ne!(mark("No?"), mark("Yes?"));
440    assert_ne!(mark("No?"), mark(""));
441}
442
443#[test]
444fn a_link_to_a_question_never_asked_unfurls_without_an_answer() {
445    let html = page("is <b> ok \"here\"?", None, "https://x.test", None, None).into_string();
446    assert!(html.contains(r#"<meta property="og:title" content="Q: is &lt;b&gt; ok &quot;here&quot;?">"#), "{html}");
447    assert!(html.contains(r#"<meta property="og:description" content="Let me Jev that for you.">"#));
448}
449
450#[test]
451fn the_home_page_unfurls_as_what_the_site_is() {
452    let html = page("", None, "https://x.test", None, None).into_string();
453    assert!(html.contains(r#"<meta property="og:title" content="Let Me Jev That For You">"#));
454    assert!(html.contains("og:description\" content=\"Ask Jev anything."));
455    assert!(html.contains(r##"<meta name="theme-color" content="#f386a1">"##));
456    assert!(html.contains(r#"<meta property="og:image" content="https://x.test/card.png">"#));
457}
458
459#[test]
460fn a_how_much_question_answered_directly_shows_the_fixed_scale() {
461    let answer = ScoreAnswer {
462        score: 2.2,
463        confidence: 0.7,
464        probabilities: vec![0.0, 0.1, 0.6, 0.3, 0.0],
465        legend: ask::DEGREES.iter().map(|level| (*level).to_owned()).collect(),
466    };
467    let mut shown = View::new("how spicy is a jalapeño".into());
468    shown.facts = Some(facts(vec![(Fact::Answerable, Judged::Noul(0.95)), (Fact::Degree, Judged::Score(answer))]));
469    read_as(&mut shown, &[Kind::Score]);
470    shown.ending = Some(Ending::Answered);
471    let html = transcript(&shown).into_string();
472    assert!(html.contains(r#"<p class="answer pick">Moderately.<span class="of"> 2.2 / 4</span></p>"#), "{html}");
473    assert_eq!(headlines(&shown), ["Moderately."]);
474    assert!(!html.contains("neurons"));
475}
476
477#[test]
478fn the_tally_says_what_jev_settled_alone_and_what_was_never_sent() {
479    let stats = Stats { questions: 3, asks: 1200, no_llm: 2, sent: 9, kept: 1, clones: 2, pulls: 0 };
480    let html = tally(Some(&stats)).into_string();
481    assert!(html.contains("<b>3 questions</b> answered, asked 1,200 times."), "{html}");
482    assert!(html.contains("<b>67%</b> of them Jev answered alone"));
483    assert!(html.contains("<b>9 requests</b> sent"));
484    assert_eq!(tally(Some(&Stats::default())).into_string(), r#"<div class="" id="tally"></div>"#);
485    assert_eq!(tally(None).into_string(), r#"<div class="" id="tally"></div>"#);
486}
487
488#[test]
489fn asking_too_fast_says_so_and_offers_no_link() {
490    let mut shown = View::new("is it?".into());
491    shown.ending = Some(Ending::Slow);
492    let html = transcript(&shown).into_string();
493    assert!(html.contains("Slow down."));
494    assert!(!html.contains("Send it to them:"));
495}
496
497#[test]
498fn the_playground_makes_every_fact_and_test_a_link() {
499    let network = Network::lmjtfy();
500    let html = playground(&network, &Known::default(), &[], "Ask Jev.", |_| "/rules?x=y".to_owned()).into_string();
501    assert_eq!(html.matches(r#"<a href="/rules?x=y""#).count(), network.alphas.len() + Fact::ALL.len());
502    assert!(html.contains("Ask Jev."));
503    assert!(html.contains("<title>The rules · LMJTFY</title>"));
504}
505
506#[test]
507fn a_question_jev_is_split_on_is_answered_both_ways_and_says_how_each_was_read() {
508    let degree = ScoreAnswer {
509        score: 3.1,
510        confidence: 0.7,
511        probabilities: vec![0.0, 0.0, 0.2, 0.6, 0.2],
512        legend: ask::DEGREES.iter().map(|level| (*level).to_owned()).collect(),
513    };
514    let mut shown = View::new("how likely is rain in November".into());
515    shown.facts = Some(facts(vec![(Fact::Yes, Judged::Noul(0.8)), (Fact::Degree, Judged::Score(degree))]));
516    read_as(&mut shown, &[Kind::Noul, Kind::Score]);
517    shown.ending = Some(Ending::Answered);
518    let html = transcript(&shown).into_string();
519    assert!(html.contains(r#"<span class="read-as"> · read as a yes or no</span>"#), "{html}");
520    assert!(html.contains(r#"<span class="read-as"> · read as a how much</span>"#));
521    assert_eq!(headlines(&shown), ["Yes.", "Very."]);
522
523    // Read one way, there is nothing to say about how.
524    read_as(&mut shown, &[Kind::Noul]);
525    assert!(!transcript(&shown).into_string().contains("read-as"));
526}
527
528#[test]
529fn the_diagram_shows_jevs_probability_for_each_reading() {
530    let kind = ChoiceAnswer {
531        choice: "yes_or_no".into(),
532        confidence: 0.4,
533        probabilities: vec![("yes_or_no".into(), 0.45), ("pick_one".into(), 0.15), ("how_much".into(), 0.4)],
534    };
535    let mut shown = View::new("how likely is rain".into());
536    shown.facts = Some(facts(vec![(Fact::Reads(Kind::Noul), Judged::Choice(kind))]));
537    read_as(&mut shown, &[Kind::Noul, Kind::Score]);
538    let html = transcript(&shown).into_string();
539    for p in [">0.45</text>", ">0.15</text>", ">0.40</text>"] {
540        assert!(html.contains(p), "{p}: {html}");
541    }
542    assert_eq!(shown.reading(), Some(Kind::Noul));
543}
544
545#[test]
546fn a_how_likely_question_is_answered_with_the_probability_and_says_it_is_a_noul() {
547    let mut shown = View::new("what are the chances of rain?".into());
548    shown.facts = Some(facts(vec![(Fact::Answerable, Judged::Noul(0.9)), (Fact::Chance, Judged::Noul(0.28))]));
549    read_as(&mut shown, &[Kind::Chance]);
550    shown.ending = Some(Ending::Answered);
551    let html = transcript(&shown).into_string();
552    assert!(html.contains(r#"<p class="answer">28%.</p>"#), "{html}");
553    let note = "Noul · p = 0.28 · Jev thinks that's unlikely";
554    assert!(html.contains(note) || html.contains(&note.replace('\'', "&#39;")), "{html}");
555    // Not a yes or a no, and nobody rewrote the question.
556    assert!(!html.contains(">No.<") && !html.contains("neurons"));
557    assert_eq!(shown.said(), [Answer { headline: "28%.".into(), detail: Detail::Chance { p: 0.28 } }]);
558}
559
560#[test]
561fn what_is_kept_of_an_answer_has_its_numbers() {
562    let pick = ChoiceAnswer { choice: "Vim".into(), confidence: 0.9, probabilities: vec![("Emacs".into(), 0.2), ("Vim".into(), 0.8)] };
563    let draft = Draft::Choice { instructions: "Which?".into(), options: Vec::new() };
564    let shown = view(vec![tool(Ok(draft), Some(jev("choice", answered(Judged::Choice(pick)))))], Some(Ending::Answered));
565    let kept = shown.said();
566    // Most likely first, whatever order they were asked in.
567    let options = vec![("Vim".to_owned(), 0.8), ("Emacs".to_owned(), 0.2)];
568    assert_eq!(kept, [Answer { headline: "Vim.".into(), detail: Detail::Choice { confidence: 0.9, options } }]);
569    assert_eq!(kept[0].line(), "Vim. · Choice · confidence 0.90 · Vim 80%, Emacs 20%");
570}