fixtures/frames.txt is the grey cat flattened at a few instants, and the Kotlin evaluator (android/.../art/Motion.kt, Flatten.kt) is tested against the very same file: the app draws what this crate defines, or one of the two tests fails. Regenerate after a deliberate change to the art or the motions:

WHISKERS_ART_BLESS=1 cargo test -p whiskers-art --test frames

then record the screenshots and look at them.

10use std::fmt::Write;
12use whiskers_art::flatten::flatten;
13use whiskers_art::motion::Motion;
14use whiskers_art::scene::{MOODS, Mouth, scene};
15use whiskers_pet::Mood;

Instants chosen away from every step boundary (the blink's 0.12 s, the ring's tenths, the dots' 0.4 s), so that f32 sines in two languages cannot land either side of a change.

19const MOMENTS: [(f32, (f32, f32)); 3] = [(0.09, (0.0, 0.0)), (0.93, (0.9, -0.6)), (2.71, (-1.0, 0.5))];
21fn render() -> String {
22    let mut out = String::from("# mood mouth t gx gy, then one quad per line: x0 y0 x1 y1 x3 y3 rrggbb alpha\n");
23    for mood in MOODS {
24        let mouths: Vec<Option<Mouth>> = if mood == Mood::Speaking { std::iter::once(None).chain(Mouth::ALL.map(Some)).collect() } else { vec![None] };
25        for mouth in mouths {
26            let nodes = scene(mood, mouth);
27            for (t, gaze) in MOMENTS {
28                let _ = writeln!(out, "= {mood:?} {} {t} {} {}", mouth.map_or("flap".to_owned(), |m| format!("{m:?}")), gaze.0, gaze.1);
29                for q in flatten(&nodes, t, gaze) {
30                    let [p0, p1, _, p3] = q.corners;
31                    let _ = writeln!(
32                        out,
33                        "{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} {:02x}{:02x}{:02x} {:.3}",
34                        p0.0, p0.1, p1.0, p1.1, p3.0, p3.1, q.color.0, q.color.1, q.color.2, q.alpha
35                    );
36                }
37            }
38        }
39    }
40    out
41}
42
43#[test]
44fn the_frames_fixture_is_what_the_art_draws() {
45    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/frames.txt");
46    let now = render();
47    if std::env::var_os("WHISKERS_ART_BLESS").is_some() {
48        std::fs::write(&path, &now).unwrap();
49        return;
50    }
51    let kept = std::fs::read_to_string(&path).expect("fixtures/frames.txt (WHISKERS_ART_BLESS=1 writes it)");
52    if kept != now {
53        let first = kept.lines().zip(now.lines()).enumerate().find(|(_, (a, b))| a != b);
54        panic!("the art or its motions changed; first difference {first:?}. Bless and re-record if it was meant.");
55    }
56}
57
58#[test]
59fn no_instant_sits_on_a_step_boundary_of_a_stepping_motion() {
60    // The fixture is only a fair test if both languages agree which side of a step they are on.
61    for motion in Motion::all().into_iter().filter(|m| m.steps()) {
62        for (t, _) in MOMENTS {
63            let eps = 0.004;
64            let (a, b, c) = (motion.at(t - eps), motion.at(t), motion.at(t + eps));
65            assert!(a == b && b == c, "{} changes within {eps} s of {t}", motion.name());
66        }
67    }
68}