oxedyne/fe2o3/fe2o3_graphics/tests/gradient_oracle.rs
6.5 KiB, 13 runs
created by r1870400018:20049, 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 | //! Shade a shape the way a browser shades it, and check the colours land in the same places. |
| 2 | //! |
| 3 | //! The unit tests for [`Gradient`] assert what its own sampler returns, which is a fair check of the |
| 4 | //! interpolation and no check at all of the conventions around it: whether a position is measured |
| 5 | //! along the axis or across it, whether the ends pad or repeat, whether a radial gradient's position |
| 6 | //! is a distance or a squared one, and whether the colour is interpolated straight or premultiplied. |
| 7 | //! Every one of those can be wrong while the sampler agrees with itself, and the tests written by the |
| 8 | //! same hand as the sampler would still pass. |
| 9 | //! |
| 10 | //! The fixtures in `tests/gradient/` close that gap. Each `.grad` file describes one gradient and the |
| 11 | //! rectangle it fills, in four line kinds both sides read, and the `.png` beside it is Chromium's |
| 12 | //! rendering of exactly that description. Nothing in the expected output originates here. |
| 13 | //! |
| 14 | //! The tolerance is two levels a channel and the worst divergence observed is one, over a mean of a |
| 15 | //! tenth. Chromium dithers a gradient to hide the banding a long, shallow ramp would otherwise show, |
| 16 | //! so its output is deliberately not the exact ramp and two renderers are not obliged to agree to |
| 17 | //! the bit. The test prints the worst divergence and the mean it saw, so a drift towards the |
| 18 | //! tolerance is visible before it crosses it. |
| 19 | //! |
| 20 | //! To regenerate the PNGs: see `tests/gradient/gen.sh`. |
| 21 | //! |
| 22 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 23 | //! Anthropic Claude |
| 24 | |
| 25 | use oxedyne_fe2o3_core::prelude::*; |
| 26 | use oxedyne_fe2o3_graphics::{ |
| 27 | colour::{ |
| 28 | Gradient, |
| 29 | Rgba, |
| 30 | Stop, |
| 31 | }, |
| 32 | path::{ |
| 33 | Bounds, |
| 34 | Path, |
| 35 | }, |
| 36 | pixmap::Pixmap, |
| 37 | png, |
| 38 | raster::FillRule, |
| 39 | transform::Transform, |
| 40 | }; |
| 41 | |
| 42 | use std::{ |
| 43 | fs, |
| 44 | path::PathBuf, |
| 45 | }; |
| 46 | |
| 47 | const SIZE: usize = 256; // the side of every fixture, in pixels; gen.sh renders at this size |
| 48 | const TOL: i32 = 2; // the furthest a channel may differ from the browser's |
| 49 | |
| 50 | const CASES: &[&str] = &["linear_v", "linear_diag", "linear_pad", "radial", "linear_alpha"]; |
| 51 | |
| 52 | /// A colour's three channels multiplied by its alpha, which is what is seen. |
| 53 | /// |
| 54 | /// The comparison is made here rather than on the straight channels because the browser's PNG has |
| 55 | /// been through a premultiplied buffer, so a colour under a very low alpha has been quantised to |
| 56 | /// nothing on the way and cannot be recovered: at an alpha of one two-hundred-and-fifty-fifth, a |
| 57 | /// saturated magenta and a black are the same picture, and demanding that two renderers agree about |
| 58 | /// which of them it was is demanding agreement about something invisible. |
| 59 | fn premul(c: Rgba) -> (u8, u8, u8) { |
| 60 | let f = |v: u8| -> u8 { (((v as u32) * (c.a as u32) + 127) / 255) as u8 }; |
| 61 | (f(c.r), f(c.g), f(c.b)) |
| 62 | } |
| 63 | |
| 64 | /// A fixture, read from its `.grad` file. |
| 65 | struct Case { |
| 66 | grad: Gradient, |
| 67 | rect: Bounds, // the rectangle it fills |
| 68 | } |
| 69 | |
| 70 | /// Reads a `.grad` fixture. The format is described in `gen.sh`, and both read it. |
| 71 | fn read_case(text: &str) -> Outcome<Case> { |
| 72 | let mut axis: Option<(f32, f32, f32, f32)> = None; |
| 73 | let mut centre: Option<(f32, f32, f32)> = None; |
| 74 | let mut stops = Vec::new(); |
| 75 | let mut rect = None; |
| 76 | for line in text.lines() { |
| 77 | let line = line.trim(); |
| 78 | if line.is_empty() || line.starts_with('#') { |
| 79 | continue; |
| 80 | } |
| 81 | let f: Vec<&str> = line.split_whitespace().collect(); |
| 82 | let num = |s: &str| -> Outcome<f32> { |
| 83 | Ok(res!(s.parse::<f32>(), Invalid, Input, Decode)) |
| 84 | }; |
| 85 | match f[0] { |
| 86 | "linear" => axis = Some(( |
| 87 | res!(num(f[1])), res!(num(f[2])), res!(num(f[3])), res!(num(f[4])), |
| 88 | )), |
| 89 | "radial" => centre = Some((res!(num(f[1])), res!(num(f[2])), res!(num(f[3])))), |
| 90 | "stop" => { |
| 91 | let hex = f[2]; |
| 92 | let ch = |i: usize| -> Outcome<u8> { |
| 93 | Ok(res!(u8::from_str_radix(&hex[i..i + 2], 16), Invalid, Input, Decode)) |
| 94 | }; |
| 95 | stops.push(Stop::new( |
| 96 | res!(num(f[1])), |
| 97 | Rgba::new(res!(ch(0)), res!(ch(2)), res!(ch(4)), res!(ch(6))), |
| 98 | )); |
| 99 | }, |
| 100 | "rect" => { |
| 101 | let (x, y, w, h) = (res!(num(f[1])), res!(num(f[2])), res!(num(f[3])), res!(num(f[4]))); |
| 102 | rect = Some(Bounds::new(x, y, x + w, y + h)); |
| 103 | }, |
| 104 | other => return Err(err!("A fixture line begins {:?}, which names nothing.", other; |
| 105 | Invalid, Input)), |
| 106 | } |
| 107 | } |
| 108 | let grad = match (axis, centre) { |
| 109 | (Some((x0, y0, x1, y1)), None) => Gradient::Linear { |
| 110 | from: (x0, y0), |
| 111 | to: (x1, y1), |
| 112 | stops, |
| 113 | }, |
| 114 | (None, Some((cx, cy, r))) => Gradient::Radial { |
| 115 | centre: (cx, cy), |
| 116 | radius: r, |
| 117 | stops, |
| 118 | }, |
| 119 | _ => return Err(err!("A fixture must name exactly one of a linear or a radial gradient."; |
| 120 | Invalid, Input)), |
| 121 | }; |
| 122 | Ok(Case { |
| 123 | grad, |
| 124 | rect: match rect { |
| 125 | Some(r) => r, |
| 126 | None => return Err(err!("A fixture must name the rectangle it fills."; |
| 127 | Invalid, Input, Missing)), |
| 128 | }, |
| 129 | }) |
| 130 | } |
| 131 | |
| 132 | #[test] |
| 133 | fn test_a_gradient_shades_where_a_browser_shades_00() -> Outcome<()> { |
| 134 | let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/gradient"); |
| 135 | let mut worst = 0i32; |
| 136 | let mut worst_case = String::new(); |
| 137 | let mut total = 0u64; |
| 138 | let mut count = 0u64; |
| 139 | |
| 140 | for name in CASES { |
| 141 | let text = res!(fs::read_to_string(dir.join(format!("{}.grad", name))), IO, File, Read); |
| 142 | let case = res!(read_case(&text)); |
| 143 | |
| 144 | let mut pm = res!(Pixmap::new(SIZE, SIZE)); |
| 145 | let path = res!(Path::rect(case.rect)); |
| 146 | res!(pm.fill_gradient(&path, &Transform::IDENTITY, &case.grad, None, FillRule::NonZero)); |
| 147 | |
| 148 | let want = res!(png::decode(&res!(fs::read(dir.join(format!("{}.png", name))), IO, File, Read))); |
| 149 | req!(want.width(), SIZE); |
| 150 | req!(want.height(), SIZE); |
| 151 | |
| 152 | for y in 0..SIZE { |
| 153 | for x in 0..SIZE { |
| 154 | let got = match pm.pixel(x, y) { |
| 155 | Some(c) => c, |
| 156 | None => return Err(err!("Our pixel ({}, {}) is off the pixmap.", x, y; |
| 157 | Invalid, Input, Range)), |
| 158 | }; |
| 159 | let exp = match want.pixel(x, y) { |
| 160 | Some(c) => c, |
| 161 | None => return Err(err!("The browser's pixel ({}, {}) is off the image.", x, y; |
| 162 | Invalid, Input, Range)), |
| 163 | }; |
| 164 | let (gp, ep) = (premul(got), premul(exp)); |
| 165 | let pairs = [(gp.0, ep.0), (gp.1, ep.1), (gp.2, ep.2), (got.a, exp.a)]; |
| 166 | for i in 0..4 { |
| 167 | let d = ((pairs[i].0 as i32) - (pairs[i].1 as i32)).abs(); |
| 168 | total += d as u64; |
| 169 | count += 1; |
| 170 | if d > worst { |
| 171 | worst = d; |
| 172 | worst_case = format!( |
| 173 | "{} at ({}, {}): ours {:?}, the browser's {:?}", name, x, y, got, exp); |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | println!( |
| 180 | "The worst channel differs by {} and the mean by {:.4}. Worst: {}", |
| 181 | worst, (total as f64) / (count as f64), worst_case, |
| 182 | ); |
| 183 | if worst > TOL { |
| 184 | return Err(err!( |
| 185 | "A gradient differs from the browser's by {} levels, over the tolerance of {}. {}", |
| 186 | worst, TOL, worst_case; |
| 187 | Invalid, Input, Mismatch)); |
| 188 | } |
| 189 | Ok(()) |
| 190 | } |