whiskers.git / web / cat / src / character.rs
1//! Who the demo's picker can show: the thirteen characters of the app, from the one list (`whiskers_art::Character`).
2//! A closed set: a name from a visitor is parsed against it and nothing else. This adds what the page alone needs:
3//! the default, the order of the picker (the default first), and the figure as the page's `<svg id="cat">`.
4
5use whiskers_art::{Character as Art, Drawn};
6
7use crate::mood::Mood;
8use crate::scene::scene;
9use crate::svg::{Mode, svg};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub struct Character(pub Art);
13
14impl Character {
15    /// The ginger cat: the default, and first in the picker.
16    pub const DEFAULT: Character = Character(Art::Drawn(Drawn::Ginger));
17
18    /// Every character, in the order the picker lists them: the default first, then the app's order.
19    pub fn all() -> Vec<Character> {
20        let mut all: Vec<Character> = Art::all().into_iter().map(Character).collect();
21        all.sort_by_key(|c| *c != Character::DEFAULT);
22        all
23    }
24
25    /// The word the page and its signals use: the app's id for the character.
26    pub fn name(self) -> &'static str {
27        self.0.id()
28    }
29
30    /// The word on the picker's button.
31    pub fn label(self) -> &'static str {
32        self.0.label()
33    }
34
35    /// What it is, for the picture's description.
36    pub fn kind(self) -> &'static str {
37        self.0.kind()
38    }
39
40    /// A name from outside, against the closed set. Anything else is `None`.
41    pub fn parse(name: &str) -> Option<Character> {
42        Art::parse(name).map(Character)
43    }
44
45    /// Like [`Character::parse`] for a value that may be missing or wrong, such as a query: the
46    /// default for anything that is not a character.
47    pub fn parse_or_default(name: Option<&str>) -> Character {
48        name.and_then(Character::parse).unwrap_or(Character::DEFAULT)
49    }
50
51    /// The character in a mood as the page's `<svg id="cat">`, with the pupils bound to the pointer by
52    /// `eyes_attrs` (raw markup the caller writes).
53    pub fn figure(self, mood: Mood, label: &str, eyes_attrs: &str) -> String {
54        svg(&scene(self, mood), label, &Mode::Live { eyes_attrs })
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn the_default_is_first_and_the_rest_keep_the_apps_order() {
64        let labels: Vec<_> = Character::all().iter().map(|c| c.label()).collect();
65        assert_eq!(labels, ["Ginger", "Grey", "Bunny", "Panda", "Koala", "Capybara", "Penguin", "Fox", "Rabbit", "Owl", "Unicorn", "Dog", "Sloth"]);
66    }
67
68    #[test]
69    fn every_name_is_unique_and_round_trips_and_nothing_else_parses() {
70        let mut names: Vec<_> = Character::all().iter().map(|c| c.name()).collect();
71        names.sort();
72        names.dedup();
73        assert_eq!(names.len(), 13);
74        for c in Character::all() {
75            assert_eq!(Character::parse(c.name()), Some(c));
76        }
77        for bad in [
78            "", "Bunny", "BUNNY", "Ginger", " bunny", "bunny ", "rabbit", "Panda", "panda ", "rabbit white", "owl", "dog", "cat", "grey", "ginger", "zebra", "whiskers", "../etc", "bunny&x=1", "grey-cat\n", "beer", "star",
79        ] {
80            assert_eq!(Character::parse(bad), None, "{bad:?}");
81        }
82        assert_eq!(Character::parse_or_default(Some("nonsense")), Character::DEFAULT);
83        assert_eq!(Character::parse_or_default(None), Character::DEFAULT);
84        assert_eq!(Character::parse_or_default(Some("owl-1")).name(), "owl-1");
85        assert_eq!(Character::parse_or_default(Some("bunny")).label(), "Bunny");
86    }
87}