refactor(color): move gradient.rs tests to gradient/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:18:17 +00:00
parent 5ce87c626f
commit 3e2c741dfb
2 changed files with 464 additions and 465 deletions
+1 -465
View File
@@ -801,468 +801,4 @@ pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_endpoints_and_mid() {
let g = Gradient::default();
assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff");
assert_eq!(g.sample(1.0).0.to_hex(), "#000000");
let mid = g.sample(0.5).0.to_rgb()[0];
assert!((mid - 0.5).abs() < 1e-5);
}
/// Sampling increasing positions in one pass gives what sampling each alone does, out-of-order
/// and repeated stop offsets included.
#[test]
fn increasing_samples_match_single_samples() {
let stop = |offset: f32, hex: &str, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let g = Gradient::new(
GradientKind::Linear,
vec![
stop(0.1, "#ff0000", 0.3),
stop(0.4, "#00ff00", 0.7),
stop(0.4, "#0000ff", 0.5),
stop(0.2, "#ffff00", 0.5),
stop(0.9, "#000000", 0.5),
],
);
let ts: Vec<f32> = (0..=40).map(|k| k as f32 / 40.0).collect();
let one_by_one: Vec<_> = ts.iter().map(|&t| g.sample_with(t, Color::lerp_keep_model)).collect();
assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one);
}
#[test]
fn midpoint_shifts() {
let mut g = Gradient::default();
g.stops[0].midpoint = 0.25;
// At t = 0.25 we should be half way.
assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5);
assert_eq!(g.expanded_stops().len(), 3);
}
#[test]
fn fit_linear_horizontal() {
let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
assert_eq!(g.start, Point::new(0.0, 25.0));
assert_eq!(g.end, Point::new(100.0, 25.0));
assert!((g.angle_deg()).abs() < 1e-9);
let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0);
assert!((v.angle_deg() - 90.0).abs() < 1e-9);
assert!((v.length() - 50.0).abs() < 1e-9);
}
#[test]
fn reverse_stops() {
let mut g = Gradient::default();
g.reverse();
assert_eq!(g.stops[0].color.to_hex(), "#000000");
assert_eq!(g.stops[0].offset, 0.0);
}
/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same
/// the other way round.
#[test]
fn reverse_mirrors_midpoints() {
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]);
let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]);
for g in [two, three] {
let mut r = g.clone();
r.reverse();
let mids: Vec<f32> = r.stops.iter().map(|s| s.midpoint).collect();
let want: Vec<f32> = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect();
assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}");
for i in 0..=20 {
let t = i as f32 / 20.0;
let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t));
let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1);
assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}");
}
r.reverse();
let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6);
assert!(back, "reversed twice, it is as it was: {:?}", r.stops);
}
}
fn close(a: Point, b: Point) -> bool {
a.distance(b) < 1e-9
}
fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom {
GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None }
}
#[test]
fn rotate_90_gives_vertical_vector() {
let mut g = linear((0.0, 0.0), (100.0, 0.0));
g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}");
assert!((g.angle_deg() + 90.0).abs() < 1e-9);
}
#[test]
fn reflect_swaps_start_and_end() {
let mut g = linear((0.0, 25.0), (100.0, 25.0));
// Reflect across the vertical axis through x = 50.
let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0));
g.transform(flip, GradientKind::Linear);
assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}");
}
#[test]
fn non_uniform_scale_turns_a_circle_into_an_ellipse() {
let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None };
g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}");
assert!((g.aspect - 0.5).abs() < 1e-9);
// A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long.
let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None };
v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}");
}
#[test]
fn samples_follow_shear_and_rotation() {
let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4);
for kind in [GradientKind::Linear, GradientKind::Radial] {
let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None };
let mut m = g;
m.transform(shear, kind);
for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] {
let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p));
assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}");
}
}
}
#[test]
fn radial_fit_uses_the_angle_and_param_honours_aspect() {
let b = Rect::new(0.0, 0.0, 100.0, 60.0);
let g = GradientGeom::fit(GradientKind::Radial, b, 90.0);
assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}");
let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None };
assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12);
assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12);
assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12);
}
fn g3() -> Gradient {
let mut g = Gradient::default();
g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0)));
g
}
#[test]
fn insert_samples_color_and_sorts() {
let g = Gradient::default();
let (stops, i) = insert_stop(&g, 0.25);
assert_eq!(stops.len(), 3);
assert_eq!(i, 1);
let r = stops[1].color.to_rgb()[0];
assert!((r - 0.75).abs() < 1e-4, "sampled {r}");
let (stops, i) = insert_stop(&g, 2.0);
assert_eq!(i, 2);
assert_eq!(stops[2].offset, 1.0);
}
#[test]
fn remove_keeps_two() {
let g = g3();
let v = remove_stop(&g.stops, 1).unwrap();
assert_eq!(v.len(), 2);
assert!(remove_stop(&v, 0).is_none());
assert!(remove_stop(&g.stops, 9).is_none());
}
#[test]
fn move_reorders_and_tracks_index() {
let g = g3();
let (v, i) = move_stop(&g.stops, 0, 0.8);
assert_eq!(i, 1);
assert_eq!(v[1].color.to_hex(), "#ffffff");
assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset));
let (v, i) = move_stop(&g.stops, 1, -3.0);
// Clamped to 0 and placed after the existing stop at 0.
assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string()));
}
#[test]
fn duplicate_copies_the_stop_to_a_new_offset() {
let g = g3();
let (v, i) = duplicate_stop(&g.stops, 1, 0.9);
assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string()));
assert_eq!(v[1], g.stops[1], "the original stays");
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
}
#[test]
fn swap_exchanges_only_the_colours() {
let mut g = g3();
g.stops[0].opacity = 0.25;
let v = swap_stop_colors(&g.stops, 0, 1);
assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string()));
assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0));
assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops);
}
#[test]
fn midpoint_math() {
let g = g3();
assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25));
assert_eq!(midpoint_pos(&g.stops, 2), None);
let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap();
assert!((m - 0.2).abs() < 1e-5);
assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13);
let v = set_midpoint(&g.stops, 0, 0.99);
assert_eq!(v[0].midpoint, 0.87);
}
#[test]
fn rebase_keeps_relative_positions() {
let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None };
g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0));
assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}");
// A box without height (a horizontal line) keeps the offset from its centre line.
let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None };
h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0));
assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}");
// Unplaced paints have nothing to move.
let mut p = GradientPaint::new(Gradient::default());
p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0));
assert_eq!(p.geom, None);
}
#[test]
fn pin_fixes_the_fit_once() {
let mut p = GradientPaint::new(Gradient::default());
p.angle = 90.0;
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
p.pin(b);
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
p.pin(Rect::new(0.0, 0.0, 1.0, 1.0));
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
}
/// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre.
fn off_centre() -> GradientGeom {
let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None };
g.set_focal(Some(Point::new(160.0, 200.0)));
g
}
#[test]
fn focal_round_trips_through_serde_and_defaults_to_none() {
let g = off_centre();
let s = serde_json::to_string(&g).unwrap();
assert!(s.contains("\"focal\""), "{s}");
assert_eq!(serde_json::from_str::<GradientGeom>(&s).unwrap(), g);
// Placements saved before focal points existed load centred, and centred ones write none.
let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap();
assert_eq!((old.focal, old.aspect), (None, 1.0));
assert!(!serde_json::to_string(&old).unwrap().contains("focal"));
}
#[test]
fn focal_follows_transforms_and_rebases() {
let mut g = off_centre();
let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5);
let f = a * g.focal.unwrap();
g.transform(a, GradientKind::Radial);
assert!(close(g.focal.unwrap(), f), "{g:?}");
// Still at the same parameter: the first stop.
assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9);
let mut h = off_centre();
h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}");
}
#[test]
fn param_runs_from_the_focal_point_to_the_ellipse() {
let g = off_centre();
let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y));
assert!(t(160.0, 200.0).abs() < 1e-9);
// The extent circle is the last stop all round.
for a in [0.0f64, 1.0, 2.5, 4.0] {
let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin());
assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}");
}
// The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point
// (0.4 of the radius off), with a radius of 2/7.
assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0));
assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier");
assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5);
}
#[test]
fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() {
let mut g = off_centre();
g.set_focal(Some(Point::new(500.0, 200.0)));
assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}");
g.set_focal(Some(Point::new(200.0, 200.0)));
assert_eq!(g.focal, None, "on the centre: centred");
// A new vector and aspect keep it in its place in the ellipse.
let before = off_centre();
let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None };
h.keep_focal_from(&before);
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
}
#[test]
fn pixels_sample_the_ramp_at_each_cell_centre() {
// White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end.
let mut g = GradientPaint::new(Gradient::default());
g.gradient.stops[0].midpoint = 0.3;
g.gradient.stops[1].opacity = 0.5;
let b = Rect::new(0.0, 0.0, 10.0, 2.0);
let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect();
assert_eq!(px.len(), 20);
let ramp = Ramp::new(&g.gradient, &Color::to_rgb);
for (i, p) in px.iter().take(10).enumerate() {
let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8);
assert_eq!(*p, c, "pixel {i}");
}
assert_eq!(px[3], px[13], "rows alike");
// The midpoint stop: grey at 30%; padded past the ends.
assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3));
assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]);
assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]);
assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]);
}
fn blue_yellow(method: GradientMethod) -> Gradient {
let mut g = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))],
);
g.method = method;
g
}
/// Illustrator 30's Perceptual method mixes colours in a perceptually even way
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).
/// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey
/// a display RGB mix gives, and its lightness climbs evenly.
#[test]
fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() {
let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb();
assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}");
let p = blue_yellow(GradientMethod::Perceptual);
let [r, g, b] = p.sample(0.5).0.to_rgb();
assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]);
assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]);
let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0];
let steps: Vec<f64> = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect();
assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}");
// Ends and opacity as before.
assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff");
assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00");
}
/// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual
/// curve (the midpoint diamond among them), more where it bends, close enough that the linear
/// ramp between them stays within half a colour level of the curve; a segment that hardly
/// bends needs few.
#[test]
fn perceptual_segments_expand_into_stops_on_the_curve() {
let pairs = [
(Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)),
(Color::BLACK, Color::rgb(1.0, 0.0, 0.0)),
(Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)),
(Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE),
];
for (a, b) in pairs {
let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
p.method = GradientMethod::Perceptual;
p.stops[0].midpoint = 0.3;
p.stops[1].opacity = 0.5;
let ex: Vec<_> = p.expanded().collect();
assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len());
assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint");
assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6));
let ramp = Ramp::new(&p, &Color::to_rgb);
for k in 0..=2000 {
let t = k as f32 / 2000.0;
let (c, o) = p.sample(t);
let [r, g, b] = c.to_rgb();
let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}");
}
}
let mut flat = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))],
);
flat.method = GradientMethod::Perceptual;
assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint");
}
/// Classic gradients expand as they always did: their stops, and a stop at each off-centre
/// midpoint, sampled in display RGB (so the renderer draws the same pixels as before).
#[test]
fn classic_gradients_expand_as_before() {
let mut g = Gradient::new(
GradientKind::Linear,
vec![
GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) },
GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) },
GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) },
GradientStop::new(1.0, Color::gray(0.3)),
],
);
// The expansion gradients had before they had a method.
let before: Vec<_> = g
.stops
.iter()
.enumerate()
.flat_map(|(i, s)| {
let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = g.sample_with(t, Color::lerp);
(t, c, o, Some(s.midpoint))
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
.collect();
assert_eq!(g.expanded().collect::<Vec<_>>(), before);
assert_eq!(before.len(), 6);
assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp));
assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model));
g.method = GradientMethod::Perceptual;
assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix");
}
/// The method is written only when Perceptual, so files from before it (and Classic
/// gradients) read as Classic and new Classic files stay as they were.
#[test]
fn method_is_saved_only_when_perceptual() {
let classic = serde_json::to_value(Gradient::default()).unwrap();
assert!(classic.get("method").is_none(), "{classic}");
let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap();
assert_eq!(old.method, GradientMethod::Classic);
let p = blue_yellow(GradientMethod::Perceptual);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["method"], "Perceptual");
assert_eq!(serde_json::from_value::<Gradient>(v).unwrap(), p);
assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual));
assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic));
assert_eq!(GradientMethod::parse("smooth"), None);
}
/// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there,
/// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke,
/// dithered or not.
#[test]
fn perceptual_gradients_draw_as_pixels() {
for fill in [true, false] {
assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill));
assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill));
let mut radial = blue_yellow(GradientMethod::Perceptual);
radial.kind = GradientKind::Radial;
radial.dither = true;
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
}
}
}
mod tests;
+463
View File
@@ -0,0 +1,463 @@
use super::*;
#[test]
fn sample_endpoints_and_mid() {
let g = Gradient::default();
assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff");
assert_eq!(g.sample(1.0).0.to_hex(), "#000000");
let mid = g.sample(0.5).0.to_rgb()[0];
assert!((mid - 0.5).abs() < 1e-5);
}
/// Sampling increasing positions in one pass gives what sampling each alone does, out-of-order
/// and repeated stop offsets included.
#[test]
fn increasing_samples_match_single_samples() {
let stop = |offset: f32, hex: &str, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let g = Gradient::new(
GradientKind::Linear,
vec![
stop(0.1, "#ff0000", 0.3),
stop(0.4, "#00ff00", 0.7),
stop(0.4, "#0000ff", 0.5),
stop(0.2, "#ffff00", 0.5),
stop(0.9, "#000000", 0.5),
],
);
let ts: Vec<f32> = (0..=40).map(|k| k as f32 / 40.0).collect();
let one_by_one: Vec<_> = ts.iter().map(|&t| g.sample_with(t, Color::lerp_keep_model)).collect();
assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one);
}
#[test]
fn midpoint_shifts() {
let mut g = Gradient::default();
g.stops[0].midpoint = 0.25;
// At t = 0.25 we should be half way.
assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5);
assert_eq!(g.expanded_stops().len(), 3);
}
#[test]
fn fit_linear_horizontal() {
let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
assert_eq!(g.start, Point::new(0.0, 25.0));
assert_eq!(g.end, Point::new(100.0, 25.0));
assert!((g.angle_deg()).abs() < 1e-9);
let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0);
assert!((v.angle_deg() - 90.0).abs() < 1e-9);
assert!((v.length() - 50.0).abs() < 1e-9);
}
#[test]
fn reverse_stops() {
let mut g = Gradient::default();
g.reverse();
assert_eq!(g.stops[0].color.to_hex(), "#000000");
assert_eq!(g.stops[0].offset, 0.0);
}
/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same
/// the other way round.
#[test]
fn reverse_mirrors_midpoints() {
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]);
let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]);
for g in [two, three] {
let mut r = g.clone();
r.reverse();
let mids: Vec<f32> = r.stops.iter().map(|s| s.midpoint).collect();
let want: Vec<f32> = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect();
assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}");
for i in 0..=20 {
let t = i as f32 / 20.0;
let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t));
let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1);
assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}");
}
r.reverse();
let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6);
assert!(back, "reversed twice, it is as it was: {:?}", r.stops);
}
}
fn close(a: Point, b: Point) -> bool {
a.distance(b) < 1e-9
}
fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom {
GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None }
}
#[test]
fn rotate_90_gives_vertical_vector() {
let mut g = linear((0.0, 0.0), (100.0, 0.0));
g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}");
assert!((g.angle_deg() + 90.0).abs() < 1e-9);
}
#[test]
fn reflect_swaps_start_and_end() {
let mut g = linear((0.0, 25.0), (100.0, 25.0));
// Reflect across the vertical axis through x = 50.
let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0));
g.transform(flip, GradientKind::Linear);
assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}");
}
#[test]
fn non_uniform_scale_turns_a_circle_into_an_ellipse() {
let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None };
g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}");
assert!((g.aspect - 0.5).abs() < 1e-9);
// A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long.
let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None };
v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}");
}
#[test]
fn samples_follow_shear_and_rotation() {
let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4);
for kind in [GradientKind::Linear, GradientKind::Radial] {
let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None };
let mut m = g;
m.transform(shear, kind);
for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] {
let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p));
assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}");
}
}
}
#[test]
fn radial_fit_uses_the_angle_and_param_honours_aspect() {
let b = Rect::new(0.0, 0.0, 100.0, 60.0);
let g = GradientGeom::fit(GradientKind::Radial, b, 90.0);
assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}");
let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None };
assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12);
assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12);
assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12);
}
fn g3() -> Gradient {
let mut g = Gradient::default();
g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0)));
g
}
#[test]
fn insert_samples_color_and_sorts() {
let g = Gradient::default();
let (stops, i) = insert_stop(&g, 0.25);
assert_eq!(stops.len(), 3);
assert_eq!(i, 1);
let r = stops[1].color.to_rgb()[0];
assert!((r - 0.75).abs() < 1e-4, "sampled {r}");
let (stops, i) = insert_stop(&g, 2.0);
assert_eq!(i, 2);
assert_eq!(stops[2].offset, 1.0);
}
#[test]
fn remove_keeps_two() {
let g = g3();
let v = remove_stop(&g.stops, 1).unwrap();
assert_eq!(v.len(), 2);
assert!(remove_stop(&v, 0).is_none());
assert!(remove_stop(&g.stops, 9).is_none());
}
#[test]
fn move_reorders_and_tracks_index() {
let g = g3();
let (v, i) = move_stop(&g.stops, 0, 0.8);
assert_eq!(i, 1);
assert_eq!(v[1].color.to_hex(), "#ffffff");
assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset));
let (v, i) = move_stop(&g.stops, 1, -3.0);
// Clamped to 0 and placed after the existing stop at 0.
assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string()));
}
#[test]
fn duplicate_copies_the_stop_to_a_new_offset() {
let g = g3();
let (v, i) = duplicate_stop(&g.stops, 1, 0.9);
assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string()));
assert_eq!(v[1], g.stops[1], "the original stays");
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
}
#[test]
fn swap_exchanges_only_the_colours() {
let mut g = g3();
g.stops[0].opacity = 0.25;
let v = swap_stop_colors(&g.stops, 0, 1);
assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string()));
assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0));
assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops);
}
#[test]
fn midpoint_math() {
let g = g3();
assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25));
assert_eq!(midpoint_pos(&g.stops, 2), None);
let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap();
assert!((m - 0.2).abs() < 1e-5);
assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13);
let v = set_midpoint(&g.stops, 0, 0.99);
assert_eq!(v[0].midpoint, 0.87);
}
#[test]
fn rebase_keeps_relative_positions() {
let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None };
g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0));
assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}");
// A box without height (a horizontal line) keeps the offset from its centre line.
let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None };
h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0));
assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}");
// Unplaced paints have nothing to move.
let mut p = GradientPaint::new(Gradient::default());
p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0));
assert_eq!(p.geom, None);
}
#[test]
fn pin_fixes_the_fit_once() {
let mut p = GradientPaint::new(Gradient::default());
p.angle = 90.0;
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
p.pin(b);
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
p.pin(Rect::new(0.0, 0.0, 1.0, 1.0));
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
}
/// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre.
fn off_centre() -> GradientGeom {
let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None };
g.set_focal(Some(Point::new(160.0, 200.0)));
g
}
#[test]
fn focal_round_trips_through_serde_and_defaults_to_none() {
let g = off_centre();
let s = serde_json::to_string(&g).unwrap();
assert!(s.contains("\"focal\""), "{s}");
assert_eq!(serde_json::from_str::<GradientGeom>(&s).unwrap(), g);
// Placements saved before focal points existed load centred, and centred ones write none.
let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap();
assert_eq!((old.focal, old.aspect), (None, 1.0));
assert!(!serde_json::to_string(&old).unwrap().contains("focal"));
}
#[test]
fn focal_follows_transforms_and_rebases() {
let mut g = off_centre();
let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5);
let f = a * g.focal.unwrap();
g.transform(a, GradientKind::Radial);
assert!(close(g.focal.unwrap(), f), "{g:?}");
// Still at the same parameter: the first stop.
assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9);
let mut h = off_centre();
h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}");
}
#[test]
fn param_runs_from_the_focal_point_to_the_ellipse() {
let g = off_centre();
let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y));
assert!(t(160.0, 200.0).abs() < 1e-9);
// The extent circle is the last stop all round.
for a in [0.0f64, 1.0, 2.5, 4.0] {
let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin());
assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}");
}
// The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point
// (0.4 of the radius off), with a radius of 2/7.
assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0));
assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier");
assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5);
}
#[test]
fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() {
let mut g = off_centre();
g.set_focal(Some(Point::new(500.0, 200.0)));
assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}");
g.set_focal(Some(Point::new(200.0, 200.0)));
assert_eq!(g.focal, None, "on the centre: centred");
// A new vector and aspect keep it in its place in the ellipse.
let before = off_centre();
let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None };
h.keep_focal_from(&before);
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
}
#[test]
fn pixels_sample_the_ramp_at_each_cell_centre() {
// White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end.
let mut g = GradientPaint::new(Gradient::default());
g.gradient.stops[0].midpoint = 0.3;
g.gradient.stops[1].opacity = 0.5;
let b = Rect::new(0.0, 0.0, 10.0, 2.0);
let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect();
assert_eq!(px.len(), 20);
let ramp = Ramp::new(&g.gradient, &Color::to_rgb);
for (i, p) in px.iter().take(10).enumerate() {
let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8);
assert_eq!(*p, c, "pixel {i}");
}
assert_eq!(px[3], px[13], "rows alike");
// The midpoint stop: grey at 30%; padded past the ends.
assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3));
assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]);
assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]);
assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]);
}
fn blue_yellow(method: GradientMethod) -> Gradient {
let mut g = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))],
);
g.method = method;
g
}
/// Illustrator 30's Perceptual method mixes colours in a perceptually even way
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).
/// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey
/// a display RGB mix gives, and its lightness climbs evenly.
#[test]
fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() {
let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb();
assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}");
let p = blue_yellow(GradientMethod::Perceptual);
let [r, g, b] = p.sample(0.5).0.to_rgb();
assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]);
assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]);
let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0];
let steps: Vec<f64> = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect();
assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}");
// Ends and opacity as before.
assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff");
assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00");
}
/// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual
/// curve (the midpoint diamond among them), more where it bends, close enough that the linear
/// ramp between them stays within half a colour level of the curve; a segment that hardly
/// bends needs few.
#[test]
fn perceptual_segments_expand_into_stops_on_the_curve() {
let pairs = [
(Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)),
(Color::BLACK, Color::rgb(1.0, 0.0, 0.0)),
(Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)),
(Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE),
];
for (a, b) in pairs {
let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
p.method = GradientMethod::Perceptual;
p.stops[0].midpoint = 0.3;
p.stops[1].opacity = 0.5;
let ex: Vec<_> = p.expanded().collect();
assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len());
assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint");
assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6));
let ramp = Ramp::new(&p, &Color::to_rgb);
for k in 0..=2000 {
let t = k as f32 / 2000.0;
let (c, o) = p.sample(t);
let [r, g, b] = c.to_rgb();
let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}");
}
}
let mut flat = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))],
);
flat.method = GradientMethod::Perceptual;
assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint");
}
/// Classic gradients expand as they always did: their stops, and a stop at each off-centre
/// midpoint, sampled in display RGB (so the renderer draws the same pixels as before).
#[test]
fn classic_gradients_expand_as_before() {
let mut g = Gradient::new(
GradientKind::Linear,
vec![
GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) },
GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) },
GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) },
GradientStop::new(1.0, Color::gray(0.3)),
],
);
// The expansion gradients had before they had a method.
let before: Vec<_> = g
.stops
.iter()
.enumerate()
.flat_map(|(i, s)| {
let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = g.sample_with(t, Color::lerp);
(t, c, o, Some(s.midpoint))
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
.collect();
assert_eq!(g.expanded().collect::<Vec<_>>(), before);
assert_eq!(before.len(), 6);
assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp));
assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model));
g.method = GradientMethod::Perceptual;
assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix");
}
/// The method is written only when Perceptual, so files from before it (and Classic
/// gradients) read as Classic and new Classic files stay as they were.
#[test]
fn method_is_saved_only_when_perceptual() {
let classic = serde_json::to_value(Gradient::default()).unwrap();
assert!(classic.get("method").is_none(), "{classic}");
let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap();
assert_eq!(old.method, GradientMethod::Classic);
let p = blue_yellow(GradientMethod::Perceptual);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["method"], "Perceptual");
assert_eq!(serde_json::from_value::<Gradient>(v).unwrap(), p);
assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual));
assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic));
assert_eq!(GradientMethod::parse("smooth"), None);
}
/// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there,
/// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke,
/// dithered or not.
#[test]
fn perceptual_gradients_draw_as_pixels() {
for fill in [true, false] {
assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill));
assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill));
let mut radial = blue_yellow(GradientMethod::Perceptual);
radial.kind = GradientKind::Radial;
radial.dither = true;
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
}
}