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.
One stretch of the tour: the cat in a mood for a while.
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.
31impl Step {
Whatever number arrives, a step: it wraps.
The first beat in mood.
Whole seconds the page waits before asking again: never less than one.
What the page sends with a request: Datastar sends every signal.
Who is on stage. Anything that is not a mascot reads as the default.
74impl Signals {
The mascot the page is showing: the default for anything that is not one.
The visitor chose a mascot. None for a word that is not one, which the Worker refuses.
What the page should show next.
The visitor chose a mood. The tour stops: they are driving. None for a word that is not
a mood, which the Worker refuses.
The same place in the tour, with the tour playing or not as it was: what changing the mascot leaves alone.
A second counted out: the next beat if the tour is playing, otherwise the same one.
The visitor pressed play or pause.
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:
| tour | the pill is for the mood on stage | the pill is for another mood |
|---|---|---|
| playing | Playing: the spinner and the countdown | Rest |
| not | Chosen: pressed, nothing moving | Rest |
"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.
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.
What the tour button offers: the thing pressing it would do.
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.
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.
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}