whiskers.git / web / site / src / demo.rs
demo.rsannotateddemo.rssource429 lines · 15.3 KB · raw

The demo cat's choreography. The Worker owns it (the page only counts seconds and asks what comes next), and the preview video plays the same tour, so what a link shows is what the page does.

5use cat::{Mascot, Mood};
6use serde::Deserialize;

One stretch of the tour: the cat in a mood for a while.

9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub struct Beat {
11    pub mood: Mood,
12    pub ms: u32,
13}

The tour, in order, and then round again. Idle comes twice: once to begin and once to rest before the cat gets dizzy and dozes off.

17pub const TOUR: [Beat; 7] = [
18    Beat { mood: Mood::Idle, ms: 2000 },
19    Beat { mood: Mood::Listening, ms: 2200 },
20    Beat { mood: Mood::Thinking, ms: 2200 },
21    Beat { mood: Mood::Speaking, ms: 3000 },
22    Beat { mood: Mood::Idle, ms: 1600 },
23    Beat { mood: Mood::Dizzy, ms: 2200 },
24    Beat { mood: Mood::Sleeping, ms: 2600 },
25];

A place in the tour. Built from untrusted numbers, so it cannot be out of range.

28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct Step(usize);
31impl Step {

Whatever number arrives, a step: it wraps.

33    pub fn new(raw: u64) -> Step {
34        Step((raw % TOUR.len() as u64) as usize)
35    }

The first beat in mood.

38    pub fn first(mood: Mood) -> Step {
39        Step(TOUR.iter().position(|beat| beat.mood == mood).expect("every mood is in the tour"))
40    }
42    pub fn index(self) -> usize {
43        self.0
44    }
45
46    pub fn next(self) -> Step {
47        Step((self.0 + 1) % TOUR.len())
48    }
49
50    pub fn beat(self) -> Beat {
51        TOUR[self.0]
52    }

Whole seconds the page waits before asking again: never less than one.

55    pub fn dwell_secs(self) -> u32 {
56        self.beat().ms.div_ceil(1000).max(1)
57    }
58}

What the page sends with a request: Datastar sends every signal.

61#[derive(Debug, Default, Deserialize)]
62pub struct Signals {
63    #[serde(default)]
64    pub mood: String,
65    #[serde(default)]
66    pub step: u64,
67    #[serde(default)]
68    pub tour: bool,

Who is on stage. Anything that is not a mascot reads as the default.

70    #[serde(default)]
71    pub mascot: String,
72}
74impl Signals {

The mascot the page is showing: the default for anything that is not one.

76    pub fn mascot(&self) -> Mascot {
77        Mascot::parse_or_default(Some(&self.mascot))
78    }
79}

The visitor chose a mascot. None for a word that is not one, which the Worker refuses.

82pub fn pick_mascot(signals: &Signals) -> Option<Mascot> {
83    Mascot::parse(&signals.mascot)
84}

What the page should show next.

87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub struct Shown {
89    pub step: Step,
90    pub tour: bool,
91}
93impl Shown {
94    pub fn mood(self) -> Mood {
95        self.step.beat().mood
96    }
97}

The visitor chose a mood. The tour stops: they are driving. None for a word that is not a mood, which the Worker refuses.

101pub fn pick(signals: &Signals) -> Option<Shown> {
102    Mood::parse(&signals.mood).map(|mood| Shown { step: Step::first(mood), tour: false })
103}

The same place in the tour, with the tour playing or not as it was: what changing the mascot leaves alone.

107pub fn stay(signals: &Signals) -> Shown {
108    Shown { step: Step::new(signals.step), tour: signals.tour }
109}

A second counted out: the next beat if the tour is playing, otherwise the same one.

112pub fn tick(signals: &Signals) -> Shown {
113    let step = Step::new(signals.step);
114    if signals.tour { Shown { step: step.next(), tour: true } } else { Shown { step, tour: false } }
115}

The visitor pressed play or pause.

118pub fn toggle(signals: &Signals) -> Shown {
119    Shown { step: Step::new(signals.step), tour: !signals.tour }
120}

How a mood's pill (its button) shows itself. A function of what is shown and nothing else, so the server draws it and the page holds no state of its own:

tourthe pill is for the mood on stagethe pill is for another mood
playingPlaying: the spinner and the countdownRest
notChosen: pressed, nothing movingRest

"Not playing" is both a tour the visitor paused and a mood the visitor picked by hand (which stops the tour): neither is going anywhere, so neither spins.

132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133pub enum Pill {
134    Rest,
135    Chosen,

The tour is on this mood. step names the beat (a new beat is a new spinner, so the countdown starts again) and secs is how long the page waits before it asks for the next.

138    Playing { step: Step, secs: u32 },
139}
141pub fn pill(shown: Shown, mood: Mood) -> Pill {
142    match (shown.mood() == mood, shown.tour) {
143        (false, _) => Pill::Rest,
144        (true, false) => Pill::Chosen,
145        (true, true) => Pill::Playing { step: shown.step, secs: shown.step.dwell_secs() },
146    }
147}

What the tour button offers: the thing pressing it would do.

150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum TourControl {
152    Play,
153    Pause,
154}
156impl TourControl {
157    pub fn label(self) -> &'static str {
158        match self {
159            TourControl::Play => "Play tour",
160            TourControl::Pause => "Pause tour",
161        }
162    }
163}
164
165pub fn tour_control(shown: Shown) -> TourControl {
166    if shown.tour { TourControl::Pause } else { TourControl::Play }
167}

The stages a message passes, in order. The demo lights the ones a mood is busy with.

170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum Stage {
172    Hear,
173    CheckAsked,
174    Think,
175    CheckReply,
176    Speak,
177}
179impl Stage {
180    pub const ALL: [Stage; 5] = [Stage::Hear, Stage::CheckAsked, Stage::Think, Stage::CheckReply, Stage::Speak];
181
182    pub fn label(self) -> &'static str {
183        match self {
184            Stage::Hear => "hears",
185            Stage::CheckAsked => "Jev checks what was said",
186            Stage::Think => "thinks of a reply",
187            Stage::CheckReply => "Jev checks the reply",
188            Stage::Speak => "says it",
189        }
190    }
191}
192
193pub fn busy_with(mood: Mood) -> &'static [Stage] {
194    match mood {
195        Mood::Listening => &[Stage::Hear],
196        Mood::Thinking => &[Stage::CheckAsked, Stage::Think, Stage::CheckReply],
197        Mood::Speaking => &[Stage::Speak],
198        Mood::Idle | Mood::Dizzy | Mood::Sleeping => &[],
199    }
200}

What the page says about a mood. Scripted: no model and no check is running behind the demo, and the page says so.

204pub struct Caption {
205    pub heading: &'static str,
206    pub line: &'static str,
207}
209pub fn caption(mood: Mood) -> Caption {
210    match mood {
211        Mood::Idle => Caption { heading: "Waiting", line: "Whiskers sits and waits. Move the pointer and its eyes follow." },
212        Mood::Listening => Caption { heading: "Listening", line: "Ears up. A young child is talking, and Whiskers hears every word." },
213        Mood::Thinking => Caption { heading: "Thinking", line: "Before anything is said, Jev checks the message, and checks the reply." },
214        Mood::Speaking => Caption { heading: "Speaking", line: "\u{201c}Cats can sleep sixteen hours a day. Want to hear why?\u{201d}" },
215        Mood::Dizzy => Caption { heading: "Dizzy", line: "Spun round and round. It gets over it." },
216        Mood::Sleeping => Caption { heading: "Sleeping", line: "Zzz. Whiskers dozes off when nobody has talked to it for a while." },
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn signals(mood: &str, step: u64, tour: bool) -> Signals {
225        Signals { mood: mood.into(), step, tour, ..Signals::default() }
226    }
227
228    #[test]
229    fn the_tour_shows_every_mood_and_wraps() {
230        for mood in Mood::ALL {
231            assert!(TOUR.iter().any(|beat| beat.mood == mood), "{mood:?}");
232        }
233        let mut step = Step::new(0);
234        for _ in 0..TOUR.len() {
235            step = step.next();
236        }
237        assert_eq!(step, Step::new(0));
238    }
239
240    #[test]
241    fn no_number_makes_a_step_out_of_range() {
242        for raw in [0, 6, 7, 8, 1_000_000, u64::MAX] {
243            assert!(Step::new(raw).index() < TOUR.len());
244        }
245    }
246
247    #[test]
248    fn every_beat_is_long_enough_to_see_and_short_enough_to_keep_the_loop_brisk() {
249        for beat in TOUR {
250            assert!((1500..=3500).contains(&beat.ms), "{beat:?}");
251        }
252        assert!(TOUR.iter().map(|b| b.ms).sum::<u32>() <= 20_000);
253    }
254
255    #[test]
256    fn picking_a_mood_shows_it_and_stops_the_tour() {
257        for mood in Mood::ALL {
258            let shown = pick(&signals(mood.name(), 3, true)).unwrap();
259            assert_eq!(shown.mood(), mood);
260            assert!(!shown.tour);
261        }
262    }
263
264    #[test]
265    fn a_word_that_is_not_a_mood_is_refused() {
266        assert_eq!(pick(&signals("angry", 0, true)), None);
267        assert_eq!(pick(&signals("", 0, true)), None);
268        assert_eq!(pick(&signals("<script>", 0, true)), None);
269    }
270
271    #[test]
272    fn a_tick_advances_a_playing_tour_and_leaves_a_paused_one() {
273        let playing = tick(&signals("idle", 0, true));
274        assert_eq!((playing.step.index(), playing.tour), (1, true));
275        let paused = tick(&signals("idle", 4, false));
276        assert_eq!((paused.step.index(), paused.tour), (4, false));
277    }
278
279    #[test]
280    fn toggling_keeps_the_place() {
281        let shown = toggle(&signals("idle", 5, false));
282        assert_eq!((shown.step.index(), shown.tour), (5, true));
283        assert!(!toggle(&signals("idle", 5, true)).tour);
284    }
285
286    fn playing(shown: Shown) -> Vec<Mood> {
287        Mood::ALL.into_iter().filter(|&m| matches!(pill(shown, m), Pill::Playing { .. })).collect()
288    }
289
290    #[test]
291    fn while_the_tour_plays_exactly_the_pill_of_the_beat_spins_for_its_dwell() {
292        for beat in 0..TOUR.len() {
293            let shown = Shown { step: Step::new(beat as u64), tour: true };
294            assert_eq!(playing(shown), vec![shown.mood()], "beat {beat}");
295            assert_eq!(pill(shown, shown.mood()), Pill::Playing { step: shown.step, secs: shown.step.dwell_secs() });
296            for mood in Mood::ALL.into_iter().filter(|&m| m != shown.mood()) {
297                assert_eq!(pill(shown, mood), Pill::Rest);
298            }
299        }
300    }
301
302    #[test]
303    fn a_paused_tour_spins_nothing_and_keeps_its_mood_pressed() {
304        for beat in 0..TOUR.len() {
305            let paused = toggle(&signals("idle", beat as u64, true));
306            assert!(!paused.tour);
307            assert!(playing(paused).is_empty(), "beat {beat}");
308            assert_eq!(pill(paused, paused.mood()), Pill::Chosen);
309        }
310    }
311
312    #[test]
313    fn a_mood_picked_by_hand_is_chosen_and_never_spins() {
314        for mood in Mood::ALL {
315            // Even if the tour was playing when the visitor pressed it.
316            for was_playing in [true, false] {
317                let shown = pick(&signals(mood.name(), 3, was_playing)).unwrap();
318                assert!(playing(shown).is_empty());
319                assert_eq!(pill(shown, mood), Pill::Chosen);
320            }
321        }
322    }
323
324    #[test]
325    fn pressing_play_after_a_pick_starts_the_spinner_on_that_mood_and_each_tick_moves_it_on() {
326        let picked = pick(&signals("thinking", 0, true)).unwrap();
327        let mut shown = toggle(&Signals { mood: "thinking".into(), step: picked.step.index() as u64, tour: picked.tour, ..Signals::default() });
328        assert_eq!(playing(shown), vec![Mood::Thinking]);
329        let mut seen = vec![shown.mood()];
330        for _ in 0..TOUR.len() {
331            shown = tick(&Signals { step: shown.step.index() as u64, tour: shown.tour, ..Signals::default() });
332            assert_eq!(playing(shown), vec![shown.mood()]);
333            seen.push(shown.mood());
334        }
335        // A full round and back where it began.
336        assert_eq!(seen.first(), seen.last());
337        // Ticking never turns a paused tour back on.
338        let paused = tick(&signals("idle", 2, false));
339        assert!(playing(paused).is_empty());
340    }
341
342    #[test]
343    fn the_tour_button_offers_what_pressing_it_does() {
344        assert_eq!(tour_control(Shown { step: Step::new(0), tour: false }), TourControl::Play);
345        assert_eq!(tour_control(Shown { step: Step::new(0), tour: true }), TourControl::Pause);
346        assert_eq!((TourControl::Play.label(), TourControl::Pause.label()), ("Play tour", "Pause tour"));
347    }
348
349    #[test]
350    fn the_page_waits_whole_seconds() {
351        for beat in 0..TOUR.len() as u64 {
352            let secs = Step::new(beat).dwell_secs();
353            assert!(secs >= 1 && secs * 1000 >= Step::new(beat).beat().ms);
354        }
355    }
356
357    #[test]
358    fn captions_and_stages_exist_for_every_mood() {
359        for mood in Mood::ALL {
360            let c = caption(mood);
361            assert!(!c.heading.is_empty() && !c.line.is_empty());
362        }
363        // The only moods busy with a message are the ones in the conversation.
364        assert!(busy_with(Mood::Idle).is_empty() && busy_with(Mood::Sleeping).is_empty());
365        assert_eq!(busy_with(Mood::Thinking).len(), 3);
366    }
367
368    // The demo's moods are the app's. These fail if a name drifts, and fail to COMPILE if the
369    // site gains a mood the app has no counterpart for.
370    fn app_mood(mood: Mood) -> whiskers_pet::Mood {
371        match mood {
372            Mood::Idle => whiskers_pet::Mood::Idle,
373            Mood::Listening => whiskers_pet::Mood::Listening,
374            Mood::Thinking => whiskers_pet::Mood::Thinking,
375            Mood::Speaking => whiskers_pet::Mood::Speaking,
376            Mood::Dizzy => whiskers_pet::Mood::Dizzy,
377            Mood::Sleeping => whiskers_pet::Mood::Sleeping,
378        }
379    }
380
381    #[test]
382    fn the_names_are_the_apps() {
383        for mood in Mood::ALL {
384            assert_eq!(format!("{:?}", app_mood(mood)).to_lowercase(), mood.name());
385        }
386    }
387
388    #[test]
389    fn the_app_s_own_rules_produce_the_moods_the_demo_shows() {
390        use whiskers_pet::{Pet, Phase, Touch, TouchKind};
391        let at = |phase, now| {
392            let mut pet = Pet::new();
393            pet.set_phase(phase, 0);
394            pet.tick(now).mood
395        };
396        assert_eq!(at(Phase::Resting, 100), app_mood(Mood::Idle));
397        assert_eq!(at(Phase::Listening, 100), app_mood(Mood::Listening));
398        assert_eq!(at(Phase::Thinking, 100), app_mood(Mood::Thinking));
399        assert_eq!(at(Phase::Speaking, 100), app_mood(Mood::Speaking));
400        assert_eq!(at(Phase::Resting, 100_000), app_mood(Mood::Sleeping));
401
402        // Two full turns round the cat inside two seconds: dizzy.
403        let mut pet = Pet::new();
404        pet.tick(0);
405        let touch = |kind, i: u64| {
406            let a = i as f32 / 60.0 * std::f32::consts::TAU * 2.0;
407            Touch { kind, x: a.cos() * 0.8, y: a.sin() * 0.8, at_ms: i * 30 }
408        };
409        pet.touch(touch(TouchKind::Down, 0));
410        for i in 1..=60 {
411            pet.touch(touch(TouchKind::Move, i));
412        }
413        assert_eq!(pet.tick(1900).mood, app_mood(Mood::Dizzy));
414    }
415
416    #[test]
417    fn the_mascot_picker_accepts_only_known_values() {
418        let with = |m: &str| Signals { mascot: m.into(), ..Signals::default() };
419        assert_eq!(pick_mascot(&with("bunny")), Some(Mascot::Bunny));
420        assert_eq!(pick_mascot(&with("grey")), Some(Mascot::Grey));
421        assert_eq!(pick_mascot(&with("ginger")), Some(Mascot::Ginger));
422        for bad in ["", "Bunny", "Ginger", "rabbit", "zebra", "whiskers", "ginger\n", "bunny,grey", "<b>"] {
423            assert_eq!(pick_mascot(&with(bad)), None, "{bad:?}");
424        }
425        // A request that sends no mascot, or nonsense, is the default one's.
426        assert_eq!(Signals::default().mascot(), Mascot::DEFAULT);
427        assert_eq!(with("zebra").mascot(), Mascot::Ginger);
428    }
429}