Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_graphics/tests/apng_oracle.rs

6.3 KiB, 11 runs

created by r1870400018:20016, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Hand an animation we wrote to two readers that know nothing about us, and check they see the
2//! frames we drew.
3//!
4//! The unit tests in `png.rs` assert on the chunks the encoder emitted -- their order, their
5//! sequence numbers, the rectangle each frame claims. That is a fair check of the framing and no
6//! check at all of whether the file animates: a frame written at the wrong offset, a difference
7//! rectangle a pixel short on its right edge, or a blend operation that composites where it should
8//! replace all produce perfectly well-formed chunks, and every one of those tests still passes.
9//! The encoder and its tests share a hand, so they share any misreading of what the fields mean.
10//!
11//! So the file is decoded here by FFmpeg and by Pillow, and the pixels compared are the ones the
12//! frames were filled with before any of this ran. Nothing in the comparison passes through this
13//! crate's own decoder. The three frames are chosen to exercise the part most likely to be wrong:
14//! the second differs from the first only in a small off-centre rectangle, so a difference computed
15//! or placed incorrectly puts the block somewhere the readers will not find it, and the third
16//! restores the first, so the canvas has to be repainted where the block was.
17//!
18//! Both readers are optional. A machine without them skips its half and says so.
19//!
20//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
21//! Anthropic Claude
22
23use oxedyne_fe2o3_core::prelude::*;
24use oxedyne_fe2o3_graphics::{
25 colour::Rgba,
26 path::Bounds,
27 pixmap::Pixmap,
28 png::{
29 Animation,
30 Delay,
31 },
32};
33
34use std::{
35 fs,
36 path::PathBuf,
37 process::Command,
38};
39
40// The canvas the fixtures are drawn on.
41const W: usize = 24;
42const H: usize = 16;
43
44// The ground colour, and the block that moves onto it.
45const GROUND: Rgba = Rgba { r: 220, g: 30, b: 40, a: 255 };
46const BLOCK: Rgba = Rgba { r: 0, g: 0, b: 255, a: 255 };
47
48// The block's rectangle: from (5, 3) to (11, 9), so six wide and six high.
49const BX0: f32 = 5.0;
50const BY0: f32 = 3.0;
51const BX1: f32 = 11.0;
52const BY1: f32 = 9.0;
53
54/// The three frames the animation holds, in order.
55fn frames() -> Outcome<Vec<Pixmap>> {
56 let a = res!(Pixmap::filled(W, H, GROUND));
57 let mut b = a.clone();
58 res!(b.fill_bounds(Bounds::new(BX0, BY0, BX1, BY1), BLOCK, None));
59 let c = a.clone();
60 Ok(vec![a, b, c])
61}
62
63/// What each frame's pixel at `(x, y)` should be, taken from the drawing rather than from the file.
64fn expected(frame: usize, x: usize, y: usize) -> Rgba {
65 let inside = frame == 1
66 && (x as f32) >= BX0 && (x as f32) < BX1
67 && (y as f32) >= BY0 && (y as f32) < BY1;
68 if inside { BLOCK } else { GROUND }
69}
70
71fn write_fixture() -> Outcome<PathBuf> {
72 let mut anim = res!(Animation::new(W, H)).plays(0);
73 for pm in res!(frames()) {
74 res!(anim.push(&pm, res!(Delay::fps(20))));
75 }
76 let buf = res!(anim.finish());
77 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("apng_oracle.png");
78 res!(fs::write(&path, &buf));
79 Ok(path)
80}
81
82/// Is the program on the path? Both spellings of the flag are tried, since FFmpeg takes one and
83/// Python the other.
84fn have(prog: &str) -> bool {
85 for flag in ["-version", "--version"] {
86 let ok = Command::new(prog)
87 .arg(flag)
88 .output()
89 .map(|o| o.status.success())
90 .unwrap_or(false);
91 if ok {
92 return true;
93 }
94 }
95 false
96}
97
98#[test]
99fn test_ffmpeg_reads_back_the_frames_that_were_drawn_00() -> Outcome<()> {
100 if !have("ffmpeg") {
101 println!("FFmpeg is not installed, so the animation's first oracle is skipped.");
102 return Ok(());
103 }
104 let path = res!(write_fixture());
105
106 // Raw RGBA on standard output, one output frame per input frame, so nothing between the
107 // decoder and the comparison can resample, retime or convert.
108 let out = res!(Command::new("ffmpeg")
109 .args(["-loglevel", "error", "-i"])
110 .arg(&path)
111 .args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "rgba", "-"])
112 .output());
113 if !out.status.success() {
114 return Err(err!(
115 "FFmpeg refused the animation: {}", String::from_utf8_lossy(&out.stderr);
116 Invalid, Input));
117 }
118
119 let want = W * H * 4;
120 req!(out.stdout.len(), want * 3);
121
122 let mut worst = None;
123 for (f, chunk) in out.stdout.chunks_exact(want).enumerate() {
124 for y in 0..H {
125 for x in 0..W {
126 let i = (y * W + x) * 4;
127 let got = Rgba::new(chunk[i], chunk[i + 1], chunk[i + 2], chunk[i + 3]);
128 let exp = expected(f, x, y);
129 if got != exp && worst.is_none() {
130 worst = Some((f, x, y, got, exp));
131 }
132 }
133 }
134 }
135 if let Some((f, x, y, got, exp)) = worst {
136 return Err(err!(
137 "FFmpeg reads frame {} at ({}, {}) as {:?}, where {:?} was drawn.", f, x, y, got, exp;
138 Invalid, Input, Mismatch));
139 }
140 println!("FFmpeg agrees on all {} pixels of all 3 frames.", want / 4 * 3);
141 Ok(())
142}
143
144#[test]
145fn test_pillow_reads_back_the_count_the_timing_and_the_block_01() -> Outcome<()> {
146 if !have("python3") {
147 println!("Python is not installed, so the animation's second oracle is skipped.");
148 return Ok(());
149 }
150 let probe = r#"
151import sys
152try:
153 from PIL import Image
154except ImportError:
155 print("SKIP")
156 sys.exit(0)
157im = Image.open(sys.argv[1])
158out = [str(im.n_frames)]
159for i in range(im.n_frames):
160 im.seek(i)
161 rgba = im.convert("RGBA")
162 out.append("%d,%d,%d" % (i, rgba.getpixel((7, 5))[0], rgba.getpixel((7, 5))[2]))
163 out.append("d%d,%s" % (i, im.info.get("duration")))
164print(";".join(out))
165"#;
166 let path = res!(write_fixture());
167 let out = res!(Command::new("python3").arg("-c").arg(probe).arg(&path).output());
168 if !out.status.success() {
169 return Err(err!(
170 "Pillow refused the animation: {}", String::from_utf8_lossy(&out.stderr);
171 Invalid, Input));
172 }
173 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
174 if text == "SKIP" {
175 println!("Pillow is not installed, so the animation's second oracle is skipped.");
176 return Ok(());
177 }
178 let parts: Vec<&str> = text.split(';').collect();
179
180 // The frame count the file declares, read by somebody else.
181 req!(parts[0], "3");
182
183 // A pixel inside the block: blue in the middle frame, ground in the two either side. That one
184 // pixel is the whole of what the difference rectangle is for.
185 req!(parts[1], "0,220,40");
186 req!(parts[3], "1,0,255");
187 req!(parts[5], "2,220,40");
188
189 // A twentieth of a second is fifty milliseconds, and Pillow reports the delay in milliseconds.
190 for i in [2usize, 4, 6] {
191 req!(parts[i], &format!("d{},50.0", (i - 2) / 2) as &str);
192 }
193 println!("Pillow agrees: {}", text);
194 Ok(())
195}