1//! The lines Whiskers says that are the same every time, so a cache of speech can hold them.
2//!
3//! A line is fixed when its words are decided in code and depend on nothing but the child's profile (and, for
4//! a voice's introduction, the voice's name): the greetings, the character chooser's hello. What came from a
5//! memory or from the model is never here, and the speech cache keeps nothing that was not declared fixed (see
6//! the service README), so forgetting a memory leaves nothing behind.
7
8use crate::greeting::{self, Variant};
9use crate::profile::Audience;
10
11/// The longest voice name said in a line: a vendor's names are sometimes descriptions.
12const VOICE_NAME_SPOKEN_MAX: usize = 40;
13
14/// What she hears when a character changes: "Hi Ada", or "Hi there" with no name. Said in the new character's
15/// own voice.
16pub fn hi_line(audience: &Audience) -> String {
17    format!("Hi {}", audience.name().map_or("there", |n| n.as_str()))
18}
19
20/// What she hears when a voice is chosen: "Hi Ada, this voice is called Jennifer". A voice with no name the
21/// service gave is introduced without one. Said in that voice.
22pub fn voice_intro_line(audience: &Audience, voice_name: Option<&str>) -> String {
23    let hi = hi_line(audience);
24    match voice_name.map(str::trim).filter(|n| !n.is_empty()) {
25        Some(name) => {
26            // A name is cut at a word boundary if it is long, so the line stays a line.
27            let spoken: String = if name.chars().count() > VOICE_NAME_SPOKEN_MAX {
28                let cut: String = name.chars().take(VOICE_NAME_SPOKEN_MAX).collect();
29                cut.rsplit_once(' ').map_or(cut.clone(), |(head, _)| head.to_owned())
30            } else {
31                name.to_owned()
32            };
33            format!("{hi}, this voice is called {spoken}")
34        }
35        None => format!("{hi}, this is my usual voice"),
36    }
37}
38
39/// Every fixed line the core knows for this audience, once each. The app adds its own (page names, hints, the
40/// time-left lines, the undo toast) to the list it hands to a pre-render.
41pub fn fixed_lines(audience: &Audience) -> Vec<String> {
42    let mut lines: Vec<String> = Variant::ALL.into_iter().map(|v| greeting::say(audience, v)).collect();
43    lines.push(hi_line(audience));
44    lines.sort();
45    lines.dedup();
46    lines
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::profile::{Age, Child, ChildName};
53
54    fn ada() -> Audience {
55        Audience::new(Some(&Child { name: ChildName::new("Ada").unwrap(), age: Age::new(6).unwrap() }))
56    }
57
58    #[test]
59    fn the_hello_and_the_introduction_are_exactly_what_she_was_promised() {
60        assert_eq!(hi_line(&ada()), "Hi Ada");
61        assert_eq!(voice_intro_line(&ada(), Some("Jennifer")), "Hi Ada, this voice is called Jennifer");
62        assert_eq!(hi_line(&Audience::new(None)), "Hi there", "with no profile the wording is neutral");
63        assert_eq!(voice_intro_line(&Audience::new(None), Some(" Jennifer ")), "Hi there, this voice is called Jennifer");
64        assert_eq!(voice_intro_line(&ada(), None), "Hi Ada, this is my usual voice");
65        assert_eq!(voice_intro_line(&ada(), Some("  ")), "Hi Ada, this is my usual voice");
66    }
67
68    #[test]
69    fn a_long_vendor_name_is_cut_at_a_word() {
70        let line = voice_intro_line(&ada(), Some("Brian - Deep, Resonant and Comforting, Perfect for Narration"));
71        assert_eq!(line, "Hi Ada, this voice is called Brian - Deep, Resonant and Comforting,");
72    }
73
74    #[test]
75    fn the_fixed_lines_are_the_greetings_and_the_hello_without_repeats() {
76        let lines = fixed_lines(&ada());
77        assert_eq!(lines.len(), 7);
78        assert!(lines.contains(&"Hi Ada".to_owned()));
79        assert!(lines.iter().all(|l| l.contains("Ada")));
80    }
81}