Files
photocraft/crates/engine/src/brush_cmds/tests.rs
T

1235 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
fn session(w: u32, h: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "background": "transparent"})).unwrap();
s
}
fn surface(s: &Session) -> Surface {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().clone()
}
fn rgba(s: &Session, x: i32, y: i32) -> [f32; 4] {
surface(s).rgba(x, y)
}
fn same_pixels(a: &Surface, b: &Surface, r: Rect) -> bool {
(r.y0..r.y1).all(|y| (r.x0..r.x1).all(|x| a.pixel(x, y) == b.pixel(x, y)))
}
#[test]
fn stroke_backwards_compatible() {
let mut s = session(80, 40);
let r = s.execute("paint.stroke", json!({"points": [[4, 20], [60, 20]], "size": 6, "color": "#0000ff"})).unwrap();
assert_eq!(r["damage"].as_array().unwrap().len(), 4);
assert_eq!(rgba(&s, 30, 20), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(rgba(&s, 30, 30)[3], 0.0);
// [x, y, pressure] with the default pressure-size brush: light pressure → thinner line.
s.execute("paint.stroke", json!({"points": [[4, 5, 0.2], [60, 5, 0.2]], "size": 10})).unwrap();
assert!(rgba(&s, 30, 5)[3] > 0.9 && rgba(&s, 30, 8)[3] < 0.01);
}
#[test]
fn stroke_accepts_full_points_brush_and_preset() {
let mut s = session(200, 100);
// 7-tuples plus a brush object with tilt-driven angle on a flat tip.
let brush = json!({"size": 30, "roundness": 0.2, "pressureSize": false, "spacing": 0.1,
"shapeDynamics": {"enabled": true, "angle": {"control": "penTilt"}}});
s.execute("paint.stroke", json!({"points": [[50, 50, 1, 0, -45, 0, 0], [52, 50, 1, 0, -45, 0, 16]], "brush": brush})).unwrap();
// Tilt up = 90°: the flat tip stands vertically.
assert!(rgba(&s, 50, 40)[3] > 0.9 && rgba(&s, 50, 60)[3] > 0.9 && rgba(&s, 60, 50)[3] < 0.01);
// Object points.
s.execute("paint.stroke", json!({"points": [{"x": 150, "y": 20}, {"x": 180, "y": 20, "pressure": 1.0}], "size": 4, "brush": {"pressureSize": false}}))
.unwrap();
assert!(rgba(&s, 165, 20)[3] > 0.9);
// Presets by name.
s.execute("paint.stroke", json!({"points": [[20, 80], [120, 80]], "preset": "Hard Round"})).unwrap();
assert!(rgba(&s, 70, 80)[3] > 0.99);
assert!(s.execute("paint.stroke", json!({"points": [[1, 1]], "preset": "Nope"})).is_err());
assert!(s.execute("paint.stroke", json!({"points": [[1, 1]], "brush": {"size": "big"}})).is_err());
}
#[test]
fn paint_commands_bound_hostile_time_and_reject_oversized_brushes() {
let mut s = session(32, 32);
let timed = s.execute(
"paint.stroke",
json!({
"points": [[16, 16, 1, 0, 0, 0, 0], [16, 16, 1, 0, 0, 0, 1.7976931348623157e308]],
"brush": {"buildUp": true, "buildUpRate": 1000, "size": 4, "pressureSize": false}
}),
);
assert!(timed.is_ok(), "the bounded airbrush catch-up should still commit: {timed:?}");
for command in ["paint.stroke", "paint.pencil"] {
let result = s.execute(command, json!({"points": [[8, 8]], "size": 1e30}));
assert!(result.is_err(), "{command} must reject an oversized brush before rasterizing");
}
let result = s.execute("paint.stroke", json!({"points": [[8, 8]], "brush": {"dualBrush": {"enabled": true, "size": 1e30}}}));
assert!(result.is_err(), "enabled dual brush dimensions are bounded too");
s.execute("tools.setBrush", json!({"size": 5000})).expect("the shared brush limit is accepted");
assert!(s.execute("tools.setBrush", json!({"size": 5001})).is_err(), "tools.setBrush must reject sizes above the shared limit");
assert_eq!(s.tools.brush.size, 5000.0, "a rejected brush update must not change the session brush");
}
#[test]
fn replay_is_deterministic() {
let params = json!({"points": [[10, 50], [60, 30, 0.7], [120, 60, 0.9], [180, 40]], "preset": "Spatter", "color": "#ff8800"});
let run = || {
let mut s = session(200, 100);
s.execute("paint.stroke", params.clone()).unwrap();
s.execute("tools.setBrush", json!({"preset": "Confetti"})).unwrap();
s.execute("paint.stroke", json!({"points": [[10, 80], [190, 80]]})).unwrap();
s
};
let (a, b) = (run(), run());
let r = Rect::new(0, 0, 200, 100);
assert!(same_pixels(&surface(&a), &surface(&b), r));
// Replaying the journal into a fresh session reproduces the pixels.
let mut c = session(200, 100);
for (id, p) in a.journal.iter().skip(1) {
c.execute(id, p.clone()).unwrap();
}
assert!(same_pixels(&surface(&a), &surface(&c), r));
// A different explicit seed changes the scatter.
let mut d = session(200, 100);
let mut p2 = params.clone();
p2["seed"] = json!(12345);
d.execute("paint.stroke", p2).unwrap();
let mut e = session(200, 100);
e.execute("paint.stroke", params).unwrap();
assert!(!same_pixels(&surface(&d), &surface(&e), r));
}
#[test]
fn pencil_is_aliased_and_auto_erases() {
let mut s = session(60, 30);
s.execute("paint.pencil", json!({"points": [[5, 10.3], [50, 10.3]], "size": 5, "color": "#000000"})).unwrap();
let surf = surface(&s);
for y in 0..30 {
for x in 0..60 {
let a = surf.rgba(x, y)[3];
assert!(a == 0.0 || a == 1.0);
}
}
s.execute("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
// Starting on a foreground pixel: paints background.
s.execute("paint.pencil", json!({"points": [[20, 10], [20, 25]], "size": 3, "autoErase": true})).unwrap();
assert_eq!(rgba(&s, 20, 22), [1.0, 1.0, 1.0, 1.0]);
// Starting elsewhere: paints foreground.
s.execute("paint.pencil", json!({"points": [[40, 25], [40, 28]], "size": 3, "autoErase": true})).unwrap();
assert_eq!(rgba(&s, 40, 26), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn mixer_brush_reservoir_and_cleaning() {
let mut s = session(200, 30);
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
s.execute(
"paint.mixerBrush",
json!({"points": [[5, 15], [195, 15]], "size": 8, "wet": 0, "load": 0, "mix": 0, "brush": {"pressureSize": false, "spacing": 0.25}}),
)
.unwrap();
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "low load dries out");
assert!(s.tools.mixer.pickup.is_none(), "cleaned after stroke");
// Wet, not cleaned: the pickup persists into the session.
s.execute("paint.mixerBrush", json!({"points": [[5, 5], [100, 5]], "size": 8, "wet": 100, "mix": 80, "cleanAfterStroke": false})).unwrap();
assert!(s.tools.mixer.pickup.is_some());
// Sample All Layers path runs.
s.execute("paint.mixerBrush", json!({"points": [[5, 25], [50, 25]], "size": 6, "sampleAllLayers": true})).unwrap();
}
#[test]
fn color_replacement_modes() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 60, "height": 40, "background": "#33993f"})).unwrap();
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
let before = rgba(&s, 30, 20);
s.execute("paint.colorReplacement", json!({"points": [[10, 20], [50, 20]], "size": 20, "mode": "color", "tolerance": 20})).unwrap();
let c = rgba(&s, 30, 20);
let lum = |c: [f32; 4]| photocraft_color::blend::lum([c[0], c[1], c[2]]);
assert!(c[0] > c[1], "{c:?}");
assert!((lum(c) - lum(before)).abs() < 0.02);
s.execute("paint.colorReplacement", json!({"points": [[10, 5], [50, 5]], "size": 6, "mode": "luminosity", "sampling": "once", "limits": "discontiguous"}))
.unwrap();
assert!((lum(rgba(&s, 30, 5)) - 0.3).abs() < 0.02);
assert!(s.execute("paint.colorReplacement", json!({"points": [[1, 1]], "mode": "bogus"})).is_err());
}
/// The live Color Replacement stroke shows what `paint.colorReplacement` commits, at 8 and 16
/// bits: red-left/blue-right halves, a stroke from red into blue in each sampling mode. The live
/// working copy is stored at the layer's depth between dabs, so 8 bits may round a level apart.
#[test]
fn live_color_replacement_matches_the_commit() {
for depth in [8, 16] {
for sampling in ["continuous", "once", "backgroundSwatch"] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 80, "height": 40, "background": "#ff0000", "depth": depth})).unwrap();
s.execute("select.rect", json!({"x": 40, "y": 0, "width": 40, "height": 40})).unwrap();
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("tools.setColors", json!({"foreground": "#00ff00", "background": "#0000ff"})).unwrap();
let all = [[10.0, 20.0], [30.0, 20.0], [50.0, 20.0], [70.0, 22.0]];
let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::<Vec<_>>();
let opts = |p: &[[f64; 2]]| json!({"points": p, "size": 12, "hardness": 50, "sampling": sampling, "tolerance": 30, "target": "pixels"});
let mut live = live_color_replacement(&s, &opts(&all[..1])).unwrap();
live.push(&pts(&all[1..3])).unwrap();
live.push(&pts(&all[3..])).unwrap();
let shown = live.doc.clone();
s.execute("paint.colorReplacement", opts(&all)).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), s.active().unwrap().doc.layer(id).unwrap().surface().unwrap());
let mut worst = 0.0f32;
// Everywhere, the stroke's finishing dab included (the live stroke previews it).
for y in 0..40 {
for x in 0..80 {
let (got, want) = (a.rgba(x, y), b.rgba(x, y));
worst = (0..4).map(|c| (got[c] - want[c]).abs()).fold(worst, f32::max);
}
}
assert!(b.rgba(10, 20)[1] > 0.5 || sampling == "backgroundSwatch", "{depth}-bit {sampling}: recoloured");
assert!(worst <= 2.0 / 255.0 + 1e-6, "{depth}-bit {sampling}: live and commit differ by {worst}");
}
}
}
/// Color Replacement recolours layer pixels only: a mask, channel or Quick Mask target is
/// refused (it would otherwise recolour the layer behind the user's back).
#[test]
fn color_replacement_refuses_non_pixel_targets() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 20, "height": 20, "background": "#ff0000"})).unwrap();
let steps = s.active().unwrap().history.entries().len();
for t in [json!("mask"), json!("quickMask"), json!({"channel": 0}), json!(3)] {
let p = json!({"points": [[10, 10]], "size": 6, "target": t});
assert!(s.execute("paint.colorReplacement", p.clone()).is_err(), "{t}");
assert!(live_color_replacement(&s, &p).is_err(), "{t}");
}
assert_eq!(s.active().unwrap().history.entries().len(), steps);
s.execute("paint.colorReplacement", json!({"points": [[10, 10]], "size": 6, "target": "pixels"})).unwrap();
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1);
}
#[test]
fn presets_save_load_delete_round_trip() {
let mut s = session(10, 10);
let n0 = s.execute("brush.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().len();
assert!(n0 >= 12);
s.execute("brush.presets.save", json!({"name": "Mine", "brush": {"size": 77, "scattering": {"enabled": true, "count": 3}}})).unwrap();
let list = s.execute("brush.presets.list", json!({"full": true})).unwrap();
let mine = list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Mine").unwrap().clone();
assert_eq!(mine["brush"]["size"], 77.0);
assert_eq!(mine["brush"]["scattering"]["count"], 3);
// Round trip: select it, read it back.
let b = s.execute("tools.setBrush", json!({"preset": "mine"})).unwrap();
assert_eq!(b["size"], 77.0);
let back: BrushSettings = serde_json::from_value(s.execute("brush.get", json!({})).unwrap()).unwrap();
assert_eq!(back, s.tools.brush);
// Overwrite, then delete.
s.execute("brush.presets.save", json!({"name": "Mine"})).unwrap();
assert_eq!(s.tools.presets.len(), n0 + 1);
s.execute("brush.presets.delete", json!({"name": "Mine"})).unwrap();
assert_eq!(s.tools.presets.len(), n0);
assert!(s.execute("brush.presets.delete", json!({"name": "Mine"})).is_err());
assert!(s.execute("brush.presets.save", json!({})).is_err());
}
/// The New Brush Preset dialog's boxes (#4490): a preset saved without Capture Brush Size or
/// Include Tool Settings leaves the tool's size, mode, opacity, flow and pressure buttons as they
/// are when picked; one saved with Include Color brings its colour back as the foreground. A preset
/// saved without the boxes picks as it always did.
#[test]
fn presets_save_captures_size_tool_settings_and_color_as_asked() {
let mut s = session(10, 10);
s.execute("tools.setBrush", json!({"size": 40, "opacity": 0.5, "flow": 0.25, "mode": "Multiply", "pressureSize": false})).unwrap();
s.tools.foreground = [1.0, 0.0, 0.0, 1.0];
s.execute("brush.presets.save", json!({"name": "Legacy"})).unwrap();
s.execute("brush.presets.save", json!({"name": "Bare", "captureSize": false, "toolSettings": false})).unwrap();
s.execute("brush.presets.save", json!({"name": "Red", "color": true})).unwrap();
let caps = |n: &str| s.tools.presets.iter().find(|p| p.name == n).unwrap().captures.clone();
assert!(caps("Legacy").is_default(), "no boxes, the captures of any other preset");
assert_eq!(caps("Bare"), PresetCaptures { size: false, tool_settings: false, color: false });
assert!(caps("Red").color);
// Change the tool, then pick each.
let other = json!({"size": 9, "opacity": 1.0, "flow": 1.0, "mode": "Normal", "pressureSize": true});
s.execute("tools.setBrush", other.clone()).unwrap();
s.tools.foreground = [0.0, 0.0, 1.0, 1.0];
s.execute("tools.setBrush", json!({"preset": "Legacy"})).unwrap();
let b = &s.tools.brush;
assert_eq!((b.size, b.opacity, b.flow, b.mode, b.pressure_size), (40.0, 0.5, 0.25, photocraft_color::BlendMode::Multiply, false), "everything it saved");
assert_eq!(s.tools.foreground, [0.0, 0.0, 1.0, 1.0], "a preset without Include Color leaves the foreground");
s.execute("tools.setBrush", other.clone()).unwrap();
s.execute("tools.setBrush", json!({"preset": "Bare"})).unwrap();
let b = &s.tools.brush;
assert_eq!((b.size, b.opacity, b.flow, b.mode, b.pressure_size), (9.0, 1.0, 1.0, photocraft_color::BlendMode::Normal, true), "the tool's own");
assert_eq!(s.tools.current_preset.as_deref(), Some("Bare"));
s.execute("tools.setBrush", json!({"preset": "Red"})).unwrap();
assert_eq!(s.tools.foreground, [1.0, 0.0, 0.0, 1.0], "Include Color sets the foreground");
// A stroke naming the preset takes the same brush.
let r = resolve_brush(&s, &json!({"preset": "Bare"}), "paint.brush").unwrap();
assert_eq!(r.size, s.tools.brush.size);
}
#[test]
fn presets_save_appends_at_the_end_and_update_overwrites_in_place() {
let mut s = session(10, 10);
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
assert_eq!(s.tools.current_preset.as_deref(), Some("Chalk"));
let n = s.tools.presets.len();
let chalk_at = s.tools.presets.iter().position(|p| p.name == "Chalk").unwrap();
// A new preset appends at the end, picked preset or not.
s.execute("brush.presets.save", json!({"name": "Mine"})).unwrap();
assert_eq!(s.tools.presets.last().unwrap().name, "Mine");
s.execute("brush.presets.save", json!({"name": "Tail"})).unwrap();
assert_eq!(s.tools.presets.last().unwrap().name, "Tail");
// A reset clears the picked preset.
s.execute("tools.setBrush", json!({"reset": true})).unwrap();
assert_eq!(s.tools.current_preset, None);
// Editing the brush keeps the picked preset current; update overwrites it in place.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
s.execute("tools.setBrush", json!({"size": 55})).unwrap();
assert_eq!(s.tools.current_preset.as_deref(), Some("Chalk"));
let r = s.execute("brush.presets.update", json!({})).unwrap();
assert_eq!(r["name"], "Chalk");
assert_eq!(s.tools.presets.len(), n + 2, "no new preset (the two saves added two)");
assert_eq!(s.tools.presets.iter().position(|p| p.name == "Chalk"), Some(chalk_at));
let chalk = s.tools.presets.iter().find(|p| p.name == "Chalk").unwrap();
assert_eq!(chalk.brush.size, 55.0);
assert!(!chalk.builtin, "an updated built-in becomes the user's preset");
// A named update of another preset.
s.execute("brush.presets.update", json!({"name": "Mine", "brush": {"size": 9}})).unwrap();
assert_eq!(s.tools.presets.iter().find(|p| p.name == "Mine").unwrap().brush.size, 9.0);
// Errors: no such preset, and no preset to update after a reset.
assert!(s.execute("brush.presets.update", json!({"name": "Nope"})).is_err());
s.execute("tools.setBrush", json!({"reset": true})).unwrap();
assert!(s.execute("brush.presets.update", json!({})).is_err());
// Deleting the current preset clears it, so a later update fails rather than guessing.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
s.execute("brush.presets.delete", json!({"name": "Chalk"})).unwrap();
assert_eq!(s.tools.current_preset, None);
assert!(s.execute("brush.presets.update", json!({})).is_err());
}
#[test]
fn a_failed_set_brush_leaves_no_current_preset_behind() {
let mut s = session(10, 10);
let before = s.tools.brush.clone();
// A rejected brush (a wrong-typed size) must not record the preset it came with.
assert!(s.execute("tools.setBrush", json!({"preset": "Chalk", "brush": {"size": "big"}})).is_err());
assert_eq!(s.tools.current_preset, None);
assert_eq!(s.tools.brush, before);
// Once the brush is accepted, the preset is recorded.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
assert_eq!(s.tools.current_preset.as_deref(), Some("Chalk"));
// A `reset` wins over a `preset` in the same call.
s.execute("tools.setBrush", json!({"preset": "Chalk", "reset": true})).unwrap();
assert_eq!(s.tools.current_preset, None);
}
#[test]
fn set_brush_merges_fields() {
let mut s = session(10, 10);
s.execute("tools.setBrush", json!({"size": 55, "shapeDynamics": {"enabled": true, "size": {"jitter": 0.5}}})).unwrap();
s.execute("tools.setBrush", json!({"shapeDynamics": {"angle": {"jitter": 0.25}}})).unwrap();
let b = &s.tools.brush;
assert_eq!(b.size, 55.0);
assert!(b.shape_dynamics.enabled && b.shape_dynamics.size.jitter == 0.5 && b.shape_dynamics.angle.jitter == 0.25);
s.execute("tools.setBrush", json!({"reset": true})).unwrap();
assert_eq!(s.tools.brush, BrushSettings::default());
// Protect texture carries the pattern across textured presets.
s.execute("tools.setBrush", json!({"preset": "Chalk", "protectTexture": true})).unwrap();
let chalk_pattern = s.tools.brush.texture.pattern.clone();
s.execute("tools.setBrush", json!({"preset": "Canvas Texture"})).unwrap();
assert_eq!(s.tools.brush.texture.pattern, chalk_pattern);
}
#[test]
fn resizing_a_brush_resizes_its_enabled_secondary_tip() {
let mut s = session(64, 64);
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 12}})).unwrap();
s.execute("tools.setBrush", json!({"size": 80})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 24.0));
s.execute("tools.setBrush", json!({"brush": {"size": 20}})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (20.0, 6.0));
// Editing both diameters in the same patch is an explicit secondary-tip size.
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"size": 18}})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 18.0));
s.execute("tools.setBrush", json!({"dualBrush": {"size": 9}})).unwrap();
assert_eq!(s.tools.brush.dual_brush.size, 9.0);
// The stroke-level size override must use the same proportional dimensions.
let for_stroke = resolve_brush(&s, &json!({"size": 80, "points": [[10, 10]]}), "paint.stroke").unwrap();
assert_eq!((for_stroke.size, for_stroke.dual_brush.size), (80.0, 18.0));
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0));
}
/// Every way of changing the main size scales the Dual Brush tip as Photoshop does (#4809): `[` /
/// `]`, the options bar / Brushes panel / Alt+right-drag (`tools.setBrush` with only a size), down
/// to 1 px and back without losing the proportion to a floor.
#[test]
fn every_size_change_scales_the_dual_tip_and_keeps_its_proportion() {
let mut s = session(32, 32);
s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap();
let sizes = |s: &Session| (s.tools.brush.size, s.tools.brush.dual_brush.size);
s.execute("tools.decreaseBrushSize", json!({})).unwrap();
assert_eq!(sizes(&s), (80.0, 16.0));
s.execute("tools.increaseBrushSize", json!({})).unwrap();
assert_eq!(sizes(&s), (100.0, 20.0));
// A coalesced drag (options bar slider, Alt+right-drag) is a run of size-only patches.
for size in [60, 10, 1] {
s.execute("tools.setBrush", json!({"brush": {"size": size}, "coalesce": "drag"})).unwrap();
}
assert_eq!(s.tools.brush.size, 1.0);
assert!((s.tools.brush.dual_brush.size - 0.2).abs() < 1e-5, "no 0.5 px floor: {}", s.tools.brush.dual_brush.size);
s.execute("tools.setBrush", json!({"brush": {"size": 100}})).unwrap();
assert_eq!(s.tools.brush.size, 100.0);
assert!((s.tools.brush.dual_brush.size - 20.0).abs() < 1e-3, "back to 20 px: {}", s.tools.brush.dual_brush.size);
// Painting with the sub-pixel dual tip works (the renderer bounds it).
s.execute("tools.setBrush", json!({"brush": {"size": 1}})).unwrap();
s.execute("paint.stroke", json!({"points": [[4, 16], [28, 16]]})).unwrap();
}
/// Picking a preset that captured its size sets both diameters from the preset: the dual tip is
/// not scaled on top of that.
#[test]
fn a_captured_preset_size_is_not_scaled_twice() {
let mut s = session(32, 32);
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap();
s.execute("brush.presets.save", json!({"name": "Dual40"})).unwrap();
s.execute("tools.setBrush", json!({"size": 200})).unwrap();
assert_eq!(s.tools.brush.dual_brush.size, 50.0);
s.execute("tools.setBrush", json!({"preset": "Dual40"})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 10.0));
}
#[test]
fn bad_sizes_fail_and_leave_the_dual_tip_alone() {
let mut s = session(32, 32);
s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap();
for bad in [json!(1e30), json!("big"), json!([1]), json!({"x": 1}), json!(null)] {
let _ = s.execute("tools.setBrush", json!({"brush": {"size": bad}}));
assert_eq!(s.tools.brush.dual_brush.size, 20.0, "{bad}");
assert_eq!(s.tools.brush.size, 100.0, "{bad}");
}
assert!(s.execute("tools.setBrush", json!({"brush": {"size": 1e30}})).is_err());
}
#[test]
fn an_uncaptured_preset_size_keeps_dual_brush_proportions() {
let mut s = session(32, 32);
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap();
s.execute("brush.presets.save", json!({"name": "Dual", "captureSize": false})).unwrap();
s.execute("tools.setBrush", json!({"size": 80})).unwrap();
s.execute("tools.setBrush", json!({"preset": "Dual"})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 20.0));
}
#[test]
fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() {
let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0);
let base = BrushSettings {
tip: TipShape::Sampled(tile(900, 700, 3)),
dual_brush: photocraft_paint::DualBrush { tip: TipShape::Sampled(tile(30, 20, 2)), ..Default::default() },
texture: photocraft_paint::Texture { pattern: photocraft_paint::Pattern::Tile(tile(64, 64, 1)), ..Default::default() },
..Default::default()
};
let cmd = "tools.setBrush";
// Untouched bitmaps survive any patch that doesn't name them.
for patch in [json!({"size": 12}), json!({"dualBrush": {"size": 9}}), json!({"texture": {"depth": 0.5}}), json!({})] {
let b = merge_brush(&base, &patch, cmd).unwrap();
assert_eq!((&b.tip, &b.dual_brush.tip, &b.texture.pattern), (&base.tip, &base.dual_brush.tip, &base.texture.pattern), "{patch}");
}
let b = merge_brush(&base, &json!({"size": 12, "dualBrush": {"size": 9}}), cmd).unwrap();
assert_eq!((b.size, b.dual_brush.size), (12.0, 9.0));
// Named ones are replaced.
let b = merge_brush(
&base,
&json!({"tip": "round", "dualBrush": {"tip": "round"}, "texture": {"pattern": {"procedural": {"style": "dots", "size": 32, "seed": 2}}}}),
cmd,
)
.unwrap();
assert_eq!((&b.tip, &b.dual_brush.tip), (&TipShape::Round, &TipShape::Round));
assert!(matches!(b.texture.pattern, photocraft_paint::Pattern::Procedural { size: 32, .. }));
// A section replaced by a non-object is an error, not a silently kept bitmap.
assert!(merge_brush(&base, &json!({"dualBrush": 3}), cmd).is_err());
assert!(merge_brush(&base, &json!(null), cmd).is_err());
// Speed: a panel edit on a big imported tip doesn't base64 the tip.
let big = BrushSettings { tip: TipShape::Sampled(tile(2500, 2500, 7)), ..Default::default() };
let t0 = std::time::Instant::now();
for i in 0..10 {
merge_brush(&big, &json!({"size": 10 + i}), cmd).unwrap();
}
let ms = t0.elapsed().as_secs_f64() * 100.0;
eprintln!("merge_brush on a 2500 px tip: {ms:.2} ms per edit");
}
#[test]
fn define_brush_from_selection() {
let mut s = session(40, 40);
assert!(s.execute("brush.defineFromSelection", json!({"name": "Blob"})).is_err(), "needs a selection");
// A black 6×4 block inside a larger selection.
s.edit("setup", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 12, 16, 16), &[0.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
let r = s.execute("brush.defineFromSelection", json!({"name": "Blob"})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(6), Some(4)));
assert_eq!(s.tools.brush.size, 6.0);
assert!(matches!(&s.tools.brush.tip, TipShape::Sampled(t) if t.width == 6 && t.height == 4 && t.get(0, 0) == 1.0));
assert!(s.tools.presets.iter().any(|p| p.name == "Blob"));
// Paint with it.
s.execute("select.deselect", json!({})).unwrap();
s.execute("paint.stroke", json!({"points": [[30, 32]], "color": "#ff0000"})).unwrap();
assert!(rgba(&s, 30, 32)[3] > 0.9);
}
#[test]
fn define_brush_from_selection_survives_allocation_failure() {
// The sampled tip is one f32 buffer over the whole selection, and the selection mask is a
// second of the same size. Both are reserved through the fallible allocator, so an allocation
// failure must be a graceful error (brush, presets, history and document untouched), never a
// panic or an OOM abort.
let mut s = session(40, 40);
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
let brush_before = s.tools.brush.clone();
let presets_before = s.tools.presets.clone();
let history_before = s.active().unwrap().history.past_len();
crate::allocation::fail_next_for_test();
let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"}));
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
assert!(result.is_err(), "an allocation failure must be reported, not panic");
assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged");
assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged");
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
}
#[test]
fn works_on_cmyk_and_16_bit() {
for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 60, "height": 30, "mode": mode, "depth": depth})).unwrap();
s.execute("paint.stroke", json!({"points": [[5, 15], [55, 15]], "preset": "Chalk", "color": "#000000"})).unwrap();
s.execute("paint.pencil", json!({"points": [[5, 5], [55, 5]], "size": 2, "color": "#000000"})).unwrap();
assert!(rgba(&s, 30, 5)[0] < 0.2, "{mode}/{depth}: {:?}", rgba(&s, 30, 5));
}
}
#[test]
fn brush_blend_mode_multiply() {
// Painting with a blend mode (options-bar "Mode"): blue × yellow = black (Multiply).
let fill = |s: &mut Session| {
s.edit("bg", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 40, 40), &[1.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
};
let mut s = session(40, 40);
fill(&mut s);
s.execute("paint.stroke", json!({"points": [[5, 20], [35, 20]], "size": 12, "color": "#0000ff", "mode": "multiply", "brush": {"hardness": 1.0}})).unwrap();
let c = rgba(&s, 20, 20);
assert!(c[0] < 0.1 && c[1] < 0.1 && c[2] < 0.1, "multiply blue×yellow ≈ black: {c:?}");
// Normal mode paints opaque blue at the same spot.
let mut s2 = session(40, 40);
fill(&mut s2);
s2.execute("paint.stroke", json!({"points": [[5, 20], [35, 20]], "size": 12, "color": "#0000ff", "brush": {"hardness": 1.0}})).unwrap();
let n = rgba(&s2, 20, 20);
assert!(n[2] > 0.9 && n[0] < 0.1, "normal paints blue: {n:?}");
}
#[test]
fn eraser_on_background_layer_paints_the_background_colour() {
// Issue #15: erasing the Background layer (locked transparency) must paint the background
// colour, not silently do nothing (opacity can't be removed there).
let mut s = Session::new();
s.execute("file.new", json!({"width": 20, "height": 20, "background": "white"})).unwrap();
s.execute("tools.setColors", json!({"background": "#ff0000"})).unwrap();
s.execute("paint.stroke", json!({"points": [[2, 10], [18, 10]], "size": 8, "erase": true, "brush": {"hardness": 1.0}})).unwrap();
let st = s.active().unwrap();
let c = st.doc.layers[0].surface().unwrap().rgba(10, 10);
assert!(c[0] > 0.9 && c[1] < 0.1 && c[2] < 0.1 && c[3] > 0.99, "erased to opaque background red: {c:?}");
// A corner the stroke didn't touch stays white.
let w = st.doc.layers[0].surface().unwrap().rgba(10, 2);
assert!(w[0] > 0.9 && w[1] > 0.9 && w[2] > 0.9, "untouched stays white: {w:?}");
}
#[test]
fn live_stroke_matches_the_committed_stroke() {
// The canvas previews strokes with `LiveStroke`; it must show what `paint.stroke` commits
// (soft edge, scatter seed), not a stand-in.
let mut s = session(200, 100);
let pts = [[10.0, 50.0, 1.0], [60.0, 30.0, 0.7], [120.0, 60.0, 0.9], [180.0, 40.0, 1.0]];
let p = json!({"points": [pts[0]], "preset": "Spatter", "brush": {"hardness": 0.0}, "smoothing": 0.0, "target": "pixels"});
let mut live = LiveStroke::begin(&s, &p).unwrap();
for c in pts[1..].chunks(2) {
let sp: Vec<StrokePoint> = c.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
assert!(!live.push(&sp).unwrap().is_empty());
}
let mut commit = p.clone();
commit["points"] = json!(pts);
commit["seed"] = json!(live.seed);
s.execute("paint.stroke", commit).unwrap();
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 200, 100)));
assert!((0..100).any(|y| (0..200).any(|x| (0.05..0.95).contains(&shown.rgba(x, y)[3]))), "soft edge previewed");
// Bad input is an error, not a panic.
assert!(LiveStroke::begin(&s, &json!({"points": []})).is_err());
assert!(LiveStroke::begin(&s, &json!({"points": [[1, 1]], "target": {"channel": 9}})).is_err());
assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err());
}
#[test]
fn stroke_coordinate_validation_keeps_the_existing_bounds() {
let edges = [StrokePoint::new(-MAX_COORD, MAX_COORD, 1.0), StrokePoint::new(MAX_COORD, -MAX_COORD, 1.0)];
assert!(check_coords(&edges, "paint.stroke").is_ok());
assert!(check_coords(&[], "paint.stroke").is_ok(), "an empty live update is allowed");
for (x, y) in [(MAX_COORD + 1.0, 0.0), (0.0, -MAX_COORD - 1.0), (f64::NAN, 0.0), (0.0, f64::INFINITY)] {
assert!(check_coords(&[StrokePoint::new(x, y, 1.0)], "paint.stroke").is_err());
}
// The command parser still rejects empty strokes and shares the same inclusive boundary.
assert!(parse_points(&json!({"points": []}), "paint.stroke").is_err());
assert!(parse_points(&json!({"points": [[MAX_COORD, -MAX_COORD]]}), "paint.stroke").is_ok());
}
#[test]
fn live_brush_and_pencil_reject_invalid_batches_without_changing_the_preview() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "depth": depth, "background": "transparent"})).unwrap();
let st = s.active().unwrap();
let (doc, revision, history) = (st.doc.clone(), st.revision, st.history.past_len());
let id = st.active_layer.unwrap();
for (cmd, erase) in [("paint.stroke", false), ("paint.stroke", true), ("paint.pencil", false)] {
let p = json!({"points": [[8, 16]], "size": 4, "erase": erase, "seed": 7});
let mut live = LiveStroke::begin_with(&s, cmd, &p).unwrap();
let mut control = LiveStroke::begin_with(&s, cmd, &p).unwrap();
// NaN first: on an unguarded renderer this fails without starting the enormous segment.
for (x, y) in [(f64::NAN, 16.0), (16.0, f64::NAN), (f64::INFINITY, 16.0), (16.0, f64::NEG_INFINITY), (1e300, 16.0), (16.0, -MAX_COORD - 1.0)] {
let shown = live.doc.clone();
let bounds = live.bounds();
let batch = [StrokePoint::new(12.0, 18.0, 1.0), StrokePoint::new(x, y, 1.0)];
let err = live.push(&batch).unwrap_err();
assert!(matches!(err, EngineError::BadParams { cmd: ref actual, ref msg }
if actual == cmd && msg.contains("point coordinates must be finite")));
assert!(std::sync::Arc::ptr_eq(&shown, &live.doc), "reject the whole batch before touching the preview");
assert_eq!(live.bounds(), bounds);
}
// A rejected batch must not advance the renderer; resuming is identical to never
// receiving it. Ordinary off-canvas points remain valid too.
let next = [StrokePoint::new(-4.0, 16.0, 1.0), StrokePoint::new(24.0, 16.0, 1.0)];
assert_eq!(live.push(&next).unwrap(), control.push(&next).unwrap());
assert_eq!(live.bounds(), control.bounds());
let (got, want) = (live.doc.layer(id).unwrap().surface().unwrap(), control.doc.layer(id).unwrap().surface().unwrap());
assert!(same_pixels(got, want, live.bounds()));
assert_eq!(got.content_bounds(), want.content_bounds());
}
let st = s.active().unwrap();
assert!(std::sync::Arc::ptr_eq(&doc, &st.doc));
assert_eq!((st.revision, st.history.past_len()), (revision, history));
}
}
#[test]
fn live_pencil_matches_the_committed_pencil_and_auto_erases() {
let mut s = session(80, 40);
s.execute("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
// A soft, low-flow session brush: the Pencil is still hard, aliased and full flow.
s.execute("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "flow": 0.3}})).unwrap();
let pts = [[5.0, 5.0, 1.0], [30.2, 17.7, 0.6], [70.0, 33.0, 1.0]];
for (auto, start) in [(false, pts[0]), (true, [30.4, 17.2, 1.0])] {
let p = json!({"points": [start], "autoErase": auto, "smoothing": 0.0});
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
let rest: Vec<StrokePoint> = pts.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
live.push(&rest).unwrap();
let mut commit = p.clone();
let mut all = vec![start];
all.extend(pts);
commit["points"] = json!(all);
commit["seed"] = json!(live.seed);
s.execute("paint.pencil", commit).unwrap();
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 80, 40)), "auto erase {auto}");
for y in 0..40 {
for x in 0..80 {
let a = shown.rgba(x, y)[3];
assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})");
}
}
}
// The second stroke started on the first one's black: Auto Erase painted white.
assert_eq!(rgba(&s, 70, 33), [1.0, 1.0, 1.0, 1.0]);
// Only the Brush and the Pencil stroke live.
assert!(LiveStroke::begin_with(&s, "paint.mixerBrush", &json!({"points": [[1, 1]]})).is_err());
assert!(LiveStroke::begin_with(&s, "paint.pencil", &json!({"points": "none"})).is_err());
}
/// Pencil latency on a 24 MP document: the time to start a live stroke, per pointer move, and
/// to commit. `cargo test -p photocraft-engine --release --lib pencil_latency -- --ignored --nocapture`
#[test]
#[ignore]
fn pencil_latency_24mp() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "white"})).unwrap();
for size in [1.0f64, 9.0, 40.0] {
let p = json!({"points": [[100.0, 100.0]], "size": size, "smoothing": 0.0});
let t = std::time::Instant::now();
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
let begin = t.elapsed().as_secs_f64() * 1000.0;
let mut steps = Vec::new();
let mut all = vec![[100.0, 100.0]];
for i in 1..=300 {
let q = [100.0 + i as f64 * 15.0, 100.0 + (i as f64 * 0.07).sin() * 900.0 + 1000.0];
all.push(q);
let t = std::time::Instant::now();
live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap();
steps.push(t.elapsed().as_secs_f64() * 1000.0);
}
let mut commit = p.clone();
commit["points"] = json!(all);
commit["seed"] = json!(live.seed);
let t = std::time::Instant::now();
s.execute("paint.pencil", commit).unwrap();
let done = t.elapsed().as_secs_f64() * 1000.0;
steps.sort_by(f64::total_cmp);
println!(
"pencil {size:>4} px: begin {begin:.2} ms, per move p50 {:.3} ms p95 {:.3} ms max {:.3} ms, commit {done:.1} ms",
steps[steps.len() / 2],
steps[steps.len() * 95 / 100],
steps[steps.len() - 1]
);
}
}
#[test]
fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() {
// #189: the preview while dragging must be the committed stroke (within 1/255), with a soft
// brush on an opaque Background with content, at any zoom (the smoothing string scales with
// it), with smoothing on, pressure, 8 and 16 bits, and points fed one pointer move at a time.
let pts: Vec<[f64; 3]> = (0..40)
.map(|i| {
let t = f64::from(i) / 39.0;
[40.0 + 120.0 * t, 60.0 + 30.0 * (t * 7.0).sin(), 0.3 + 0.7 * t]
})
.collect();
for depth in [8, 16] {
for zoom in [0.25, 1.0, 4.0, 16.0] {
for (amount, pulled) in [(0.1, false), (0.5, false), (0.3, true)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 120, "depth": depth, "background": "white"})).unwrap();
s.execute("edit.fill", json!({"color": "#1a2550"})).unwrap();
s.execute("paint.stroke", json!({"points": [[20, 20], [180, 100]], "size": 8, "hardness": 1.0, "color": "#ffffff"})).unwrap();
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
let brush = json!({"size": 40, "hardness": 0.0, "pressureSize": true, "smoothing": {"amount": amount, "pulledString": pulled}});
let p = json!({"points": [pts[0]], "brush": brush, "zoom": zoom, "target": "pixels"});
let mut live = LiveStroke::begin(&s, &p).unwrap();
for q in &pts[1..] {
live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap();
}
let mut commit = p.clone();
commit["points"] = json!(pts);
commit["seed"] = json!(live.seed);
s.execute("paint.stroke", commit).unwrap();
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
let done = surface(&s);
let worst = (0..120).flat_map(|y| (0..200).map(move |x| (x, y))).map(|(x, y)| {
let (a, b) = (shown.rgba(x, y), done.rgba(x, y));
(0..4).map(|i| (a[i] - b[i]).abs()).fold(0.0f32, f32::max)
});
let worst = worst.fold(0.0f32, f32::max);
let label = format!("depth {depth}, zoom {zoom}, smoothing {amount} (pulled string {pulled})");
assert!(worst <= 1.0 / 255.0, "{label}: preview differs from the commit by {worst}");
// Not two blank canvases: the soft stroke is there, edge included.
assert!(done.rgba(40, 60)[0] < 0.1 && (0.05..0.9).contains(&done.rgba(40, 60 - 14)[2]), "{label}: soft stroke");
}
}
}
}
#[test]
fn new_brush_controls_round_trip_through_set_brush() {
let mut s = session(40, 40);
let patch = json!({
"spacingEnabled": false,
"shapeDynamics": {"tiltScale": 1.5, "brushProjection": true},
"transfer": {"wetness": {"jitter": 0.3, "control": "penPressure"}, "mix": {"jitter": 0.6, "minimum": 0.2}},
"locks": {"shapeDynamics": true, "texture": true, "noise": true},
"mixer": {"wet": 0.8, "load": 0.25, "mix": 0.4, "flow": 0.9, "sampleAllLayers": true},
});
s.execute("tools.setBrush", json!({ "brush": patch })).unwrap();
let b = &s.tools.brush;
assert!(!b.spacing_enabled && b.shape_dynamics.brush_projection && b.shape_dynamics.tilt_scale == 1.5);
assert_eq!(b.transfer.wetness.control, photocraft_paint::Control::PenPressure);
assert_eq!((b.transfer.mix.jitter, b.transfer.mix.minimum), (0.6, 0.2));
assert!(b.locks.shape_dynamics && b.locks.texture && b.locks.noise && !b.locks.scattering);
assert_eq!((b.mixer.wet, b.mixer.load, b.mixer.mix, b.mixer.flow, b.mixer.sample_all_layers), (0.8, 0.25, 0.4, 0.9, true));
// brush.get reports them, and a fresh session gets the same brush from the reported JSON.
let got = s.execute("brush.get", json!({})).unwrap();
for (path, want) in [
("/spacingEnabled", json!(false)),
("/shapeDynamics/tiltScale", json!(1.5)),
("/shapeDynamics/brushProjection", json!(true)),
("/transfer/wetness/control", json!("penPressure")),
("/locks/texture", json!(true)),
("/mixer/sampleAllLayers", json!(true)),
("/mixer/load", json!(0.25)),
] {
assert_eq!(got.pointer(path), Some(&want), "{path}");
}
let mut t = session(40, 40);
t.execute("tools.setBrush", json!({ "brush": got })).unwrap();
assert_eq!(t.tools.brush, s.tools.brush);
// Locked sections survive picking a preset through the command.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
assert!(s.tools.brush.shape_dynamics.brush_projection && s.tools.brush.locks.texture);
// Wrong types are errors and leave the brush alone.
let before = s.tools.brush.clone();
for bad in
[json!({"spacingEnabled": 3}), json!({"locks": {"texture": "yes"}}), json!({"mixer": {"wet": "wet"}}), json!({"shapeDynamics": {"tiltScale": []}})]
{
assert!(s.execute("tools.setBrush", json!({ "brush": bad })).is_err());
}
assert_eq!(s.tools.brush, before);
}
#[test]
fn mixer_brush_uses_the_brush_mixer_settings() {
let mut s = session(200, 30);
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
// No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply.
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap();
s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap();
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out");
// A param still overrides the brush.
s.execute("paint.mixerBrush", json!({"points": [[5, 5], [195, 5]], "size": 8, "load": 100})).unwrap();
assert!(rgba(&s, 190, 5)[3] > 0.95);
}
/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all
/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off.
fn mixer_over_red() -> Session {
let mut s = session(200, 30);
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap();
let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false});
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap();
s
}
fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] {
s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap();
rgba(s, 100, y)
}
#[test]
fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() {
let mut s = mixer_over_red();
s.execute("brush.mixer.load", json!({})).unwrap();
let state = s.execute("brush.mixer.clean", json!({})).unwrap();
assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null));
let c = mixer_row(&mut s, 15);
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}");
}
#[test]
fn the_automatic_clean_empties_the_reservoir() {
let mut s = mixer_over_red();
// The brush was never loaded, so the first stroke takes the foreground.
assert!(mixer_row(&mut s, 5)[2] > 0.2);
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None));
let c = mixer_row(&mut s, 15);
assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}");
// With the automatic Load on as well (the default), the brush is cleaned and then refilled
// after the stroke, so the next one starts with a full reservoir and no pickup.
s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap();
mixer_row(&mut s, 25);
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None));
}
#[test]
fn load_brush_after_a_clean_paints_the_foreground_again() {
let mut s = mixer_over_red();
s.execute("brush.mixer.clean", json!({})).unwrap();
assert!(mixer_row(&mut s, 5)[2] < 0.01);
let state = s.execute("brush.mixer.load", json!({})).unwrap();
assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0]));
assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue");
// A color param loads that color instead of the foreground, and the next stroke paints it.
s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap();
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0));
assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0]));
let c = mixer_row(&mut s, 25);
assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}");
}
#[test]
fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() {
let mut s = mixer_over_red();
assert!(mixer_row(&mut s, 5)[2] > 0.2);
s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap();
let c = mixer_row(&mut s, 15);
assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}");
// The automatic Clean emptied it again, and the foreground did not change.
let c = mixer_row(&mut s, 25);
assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
}
#[test]
fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() {
let mut s = mixer_over_red();
s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap();
let before = s.tools.mixer.clone();
for bad in [
json!({"color": 5}),
json!({"color": "#zz0000"}),
json!({"color": "#ff"}),
json!({"color": [2, 0, 0]}),
json!({"color": [0, 0]}),
json!({"color": [0, 0, 0, 0, 0]}),
json!({"color": {"r": 1}}),
json!({"color": ["a", 0, 0]}),
] {
assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}");
assert_eq!(s.tools.mixer, before, "{bad}");
}
// Clean Brush takes no params, so anything extra is ignored, without a document too.
let mut empty = Session::new();
empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap();
empty.execute("brush.mixer.load", json!({"color": null})).unwrap();
assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground));
}
#[test]
fn coalesced_set_brush_calls_journal_once_per_gesture() {
let mut s = session(20, 20);
let n = s.journal.len();
for k in 1..=10 {
s.execute("tools.setBrush", json!({"brush": {"opacity": k as f32 / 20.0}, "coalesce": "bar:1"})).unwrap();
s.execute("brush.get", json!({})).unwrap();
}
s.execute("tools.setBrush", json!({"brush": {"shapeDynamics": {"tiltScale": 0.5}}, "coalesce": "bar:1"})).unwrap();
assert_eq!(s.journal.len(), n + 1, "one gesture, one entry");
let (id, p) = s.journal.last().unwrap();
assert_eq!(id, "tools.setBrush");
assert_eq!(p["brush"], json!({"opacity": 0.5, "shapeDynamics": {"tiltScale": 0.5}}));
// A new gesture is a new entry; so is an uncoalesced call.
s.execute("tools.setBrush", json!({"brush": {"flow": 0.5}, "coalesce": "bar:2"})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"flow": 0.4}, "coalesce": "bar:2"})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"size": 33}})).unwrap();
assert_eq!(s.journal.len(), n + 3);
// Another command in between ends the gesture.
s.execute("tools.setBrush", json!({"brush": {"size": 34}, "coalesce": "bar:3"})).unwrap();
s.execute("paint.stroke", json!({"points": [[2, 2], [10, 10]]})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"size": 35}, "coalesce": "bar:3"})).unwrap();
assert_eq!(s.journal.len(), n + 6);
// Replay gives the same brush.
let mut t = session(20, 20);
for (id, p) in s.journal.iter().skip(1) {
t.execute(id, p.clone()).unwrap();
}
assert_eq!(t.tools.brush, s.tools.brush);
}
/// A Scatter amount beyond the 1000 % maximum (the issue's 1e38, just above the maximum, or one
/// that only fits as infinity) is rejected naming the value, before the session brush changes, by
/// `tools.setBrush` and by a stroke's own brush patch; the maximum itself and an ordinary amount
/// above 100 % still paint (#977).
#[test]
fn out_of_range_scatter_is_rejected_and_the_maximum_still_paints() {
let new = || {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s
};
let scatter = |j: f64| json!({"scattering": {"enabled": true, "bothAxes": true, "scatter": {"jitter": j}}});
let dual = |j: f64| json!({"dualBrush": {"enabled": true, "bothAxes": true, "size": 6, "scatter": j}});
let stroke = json!({"points": [[10, 10]]});
for (j, shown) in [(1e38, "1e38"), (10.01, "10.01"), (1e39, "inf"), (-1.0, "-1.0")] {
for (patch, what) in [(scatter(j), "scatter"), (dual(j), "dual brush scatter")] {
let mut s = new();
let before = s.tools.brush.clone();
let err = s.execute("tools.setBrush", json!({ "brush": patch.clone() })).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, EngineError::BadParams { .. }) && msg.contains(what) && msg.contains(shown), "{j}: {msg}");
assert_eq!(s.tools.brush, before, "a rejected scatter must not change the session brush");
let (doc, revision) = (s.active().unwrap().doc.clone(), s.active().unwrap().revision);
let err = paint_stroke(&mut s, &json!({"points": [[10, 10]], "brush": patch})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }) && err.to_string().contains(shown), "{j}: {err}");
assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc) && s.active().unwrap().revision == revision);
}
}
// The issue's repro, end to end: a stroke after the rejected update still paints normally.
let mut s = new();
assert!(s.execute("tools.setBrush", json!({ "brush": scatter(1e38) })).is_err());
assert!(s.execute("paint.stroke", stroke.clone()).is_ok());
// The maximum (1000 %) is accepted and paints a finite, well-formed dab.
for patch in [scatter(10.0), dual(10.0)] {
let mut s = new();
s.execute("tools.setBrush", json!({ "brush": patch })).unwrap();
let r = s.execute("paint.stroke", stroke.clone()).unwrap();
let d: Vec<i64> = r["damage"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
assert!(d[0].abs() < 1000 && d[1].abs() < 1000 && d[2] > 0 && d[3] > 0, "{r}");
}
// Control: 200 % scatter paints where it did before the cap (the soft default tip reaches a
// little past its radius).
let mut s = new();
s.execute("tools.setBrush", json!({ "brush": scatter(2.0) })).unwrap();
assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [15, 12, 27, 27]}));
}
#[test]
fn strokes_convert_through_the_documents_cmyk_profile() {
// A synthetic uncoated-like profile: heavier dot gain than the built-in coated CMYK (whose
// params are the defaults here), so painted ink differs from the default conversion. The
// compositors and composite exports enter the document's space; the stroke must too (#583).
let profile = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams {
description: "Stroke Test Uncoated".into(),
tvi: [0.26, 0.26, 0.26, 0.3],
grid_a2b: 5,
grid_b2a: 9,
..Default::default()
})
.to_bytes();
let cmyk_session = |tagged: bool| {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "mode": "cmyk", "background": "white"})).unwrap();
if tagged {
s.edit("icc", |doc, _| {
doc.icc_profile = Some(profile.clone());
Ok(())
})
.unwrap();
}
s
};
let params = json!({"points": [[4, 16], [28, 16]], "size": 8, "hardness": 1, "opacity": 1, "flow": 1, "color": "#cc3366", "smoothing": 0});
let mut s = cmyk_session(true);
// The live preview enters the same space, so it commits the pixels it shows.
let mut live =
LiveStroke::begin(&s, &json!({"points": [[4, 16]], "size": 8, "hardness": 1, "opacity": 1, "flow": 1, "color": "#cc3366", "smoothing": 0})).unwrap();
live.push(&[StrokePoint::new(28.0, 16.0, 1.0)]).unwrap();
let mut commit = params.clone();
commit["seed"] = json!(live.seed);
s.execute("paint.stroke", commit).unwrap();
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 32, 32)), "the preview commits the pixels it shows");
// The stroke's ink differs from the same stroke without the embedded profile.
let ink = |surf: &Surface| surf.read_region(Rect::new(15, 16, 17, 18));
let tagged = ink(&surface(&s));
let mut plain = cmyk_session(false);
plain.execute("paint.stroke", params).unwrap();
let untagged = ink(&surface(&plain));
assert!(tagged != untagged, "the document's CMYK profile must convert the painted ink: {tagged:?} vs {untagged:?}");
}
/// A live stroke over `pts` (pushed in chunks, as the canvas does) and its commit params.
fn live_and_commit(s: &Session, cmd: &str, base: Value, pts: &[[f64; 3]]) -> (LiveStroke, Value) {
let mut p = base;
p["points"] = json!([pts[0]]);
let mut live = LiveStroke::begin_with(s, cmd, &p).unwrap();
for c in pts[1..].chunks(2) {
let sp: Vec<StrokePoint> = c.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
live.push(&sp).unwrap();
}
p["points"] = json!(pts);
p["seed"] = json!(live.seed);
(live, p)
}
#[test]
fn a_commit_takes_the_live_strokes_pixels_and_they_equal_a_fresh_render() {
let pts = [[10.0, 50.0, 1.0], [60.0, 30.0, 0.7], [120.0, 60.0, 0.9], [180.0, 40.0, 0.4], [150.0, 80.0, 1.0]];
for (cmd, base) in [
("paint.stroke", json!({"brush": {"size": 18, "hardness": 0.0, "flow": 0.5}, "smoothing": 0.5, "target": "pixels"})),
("paint.stroke", json!({"preset": "Spatter", "smoothing": 0.0, "target": "pixels"})),
("paint.stroke", json!({"brush": {"size": 12}, "erase": true, "target": "pixels"})),
("paint.pencil", json!({"smoothing": 0.0})),
] {
let mut s = session(200, 100);
let (live, p) = live_and_commit(&s, cmd, base.clone(), &pts);
let steps = s.active().unwrap().history.past_len();
s.offer_live_stroke(live).unwrap();
let r = s.execute(cmd, p.clone()).unwrap();
s.clear_live_stroke();
assert_eq!(r["fromLive"], true, "{cmd} {base}: the commit took the live pixels");
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step");
// The journal's replay renders from scratch and must give the same pixels.
let mut fresh = session(200, 100);
let r = fresh.execute(cmd, p).unwrap();
assert!(r.get("fromLive").is_none());
assert!(same_pixels(&surface(&s), &surface(&fresh), Rect::new(0, 0, 200, 100)), "{cmd} {base}");
// Undo restores the pre-stroke pixels.
s.undo();
assert!(same_pixels(&surface(&s), &surface(&session(200, 100)), Rect::new(0, 0, 200, 100)));
}
}
#[test]
fn an_offer_that_does_not_match_the_commit_is_ignored() {
let pts = [[10.0, 50.0, 1.0], [60.0, 30.0, 0.7], [120.0, 60.0, 0.9]];
let base = json!({"brush": {"size": 10}, "smoothing": 0.0, "target": "pixels"});
// Different points.
let mut s = session(200, 100);
let (live, mut p) = live_and_commit(&s, "paint.stroke", base.clone(), &pts);
p["points"] = json!([[10.0, 50.0, 1.0], [190.0, 90.0, 1.0]]);
s.offer_live_stroke(live).unwrap();
let r = s.execute("paint.stroke", p.clone()).unwrap();
s.clear_live_stroke();
assert!(r.get("fromLive").is_none());
let mut fresh = session(200, 100);
fresh.execute("paint.stroke", p).unwrap();
assert!(same_pixels(&surface(&s), &surface(&fresh), Rect::new(0, 0, 200, 100)));
// The document changed after the stroke began.
let mut s = session(200, 100);
let (live, p) = live_and_commit(&s, "paint.stroke", base.clone(), &pts);
s.execute("layer.new.layer", json!({})).unwrap();
s.offer_live_stroke(live).unwrap();
let r = s.execute("paint.stroke", p).unwrap();
s.clear_live_stroke();
assert!(r.get("fromLive").is_none(), "a stale offer is never used");
// Another command leaves an offer untaken, and a later unrelated stroke can't use it.
let mut s = session(200, 100);
let (live, _) = live_and_commit(&s, "paint.stroke", base.clone(), &pts);
s.offer_live_stroke(live).unwrap();
let mut other = base;
other["points"] = json!([[5.0, 5.0, 1.0], [40.0, 5.0, 1.0]]);
let r = s.execute("paint.stroke", other).unwrap();
assert!(r.get("fromLive").is_none());
}
#[test]
fn set_brush_takes_a_per_dynamic_curve_and_a_patch_resets_it() {
let mut s = session(20, 20);
let curve = json!([[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]);
s.execute("tools.setBrush", json!({"shapeDynamics": {"enabled": true, "size": {"control": "penPressure", "curve": curve}}})).unwrap();
let with = s.tools.brush.clone();
assert_eq!(with.shape_dynamics.size.curve.points(), vec![[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]);
assert!(with.transfer.opacity.curve.is_linear(), "other dynamics keep their own curve");
// The panel's diff back to linear is a real patch, and applying it restores the linear curve.
let mut linear = with.clone();
linear.shape_dynamics.size.curve = photocraft_paint::ResponseCurve::LINEAR;
let patch = brush_patch(&with, &linear);
assert_eq!(patch["shapeDynamics"]["size"]["curve"], json!([[0.0, 0.0], [1.0, 1.0]]));
s.execute("tools.setBrush", patch).unwrap();
assert!(s.tools.brush.shape_dynamics.size.curve.is_linear());
}
#[test]
fn last_brush_is_remembered_when_the_revision_moves_only() {
let mut s = session(8, 8);
restore_brush(&mut s);
// A restore is already in the preferences: nothing to write, no clone or serialise.
s.prefs.edit(|p| p.last_brush = json!("sentinel"));
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush, json!("sentinel"), "an unchanged brush must not serialise");
s.execute("tools.setBrush", json!({"size": 23})).unwrap();
assert!(s.tools.brush_rev > s.tools.brush_saved_rev);
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush["brush"]["size"].as_f64(), Some(23.0));
assert_eq!(s.tools.brush_saved_rev, s.tools.brush_rev, "the revision is marked saved");
// Revision unchanged: even a hand-written value stays (the gate is a counter compare).
s.prefs.edit(|p| p.last_brush = json!("sentinel"));
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush, json!("sentinel"));
}
#[test]
fn rejected_brush_changes_do_not_move_the_revision() {
let mut s = session(8, 8);
let rev = s.tools.brush_rev;
assert!(s.execute("tools.setBrush", json!({"size": "big"})).is_err());
assert_eq!(s.tools.brush_rev, rev, "a rejected brush leaves no change behind");
s.execute("tools.setBrush", json!({"size": 31})).unwrap();
assert!(s.tools.brush_rev > rev);
}
#[test]
fn the_revision_tracks_the_picked_preset_too() {
let mut s = session(8, 8);
s.execute("tools.setBrush", json!({"preset": "Hard Round"})).unwrap();
let rev = s.tools.brush_rev;
s.execute("brush.presets.setCurrent", json!({"name": "Soft Round"})).unwrap();
assert!(s.tools.brush_rev > rev, "the remembered preset name follows the picked preset");
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush["preset"].as_str(), Some("Soft Round"));
}
#[test]
fn the_remembered_brush_restores_at_launch() {
let mut s = session(8, 8);
s.execute("tools.setBrush", json!({"preset": "Hard Round", "size": 44, "hardness": 0.3})).unwrap();
remember_brush(&mut s);
let stored = s.prefs().last_brush.clone();
assert_eq!(stored["preset"].as_str(), Some("Hard Round"));
let mut t = Session::new();
assert_ne!(t.tools.brush.size, 44.0, "a fresh session starts on the default brush");
t.prefs.edit(|p| p.last_brush = stored);
restore_brush(&mut t);
assert_eq!(t.tools.brush.size, 44.0);
assert_eq!(t.tools.brush.hardness, 0.3);
assert_eq!(t.tools.current_preset.as_deref(), Some("Hard Round"));
assert_eq!(t.tools.brush_saved_rev, t.tools.brush_rev, "the restored value is already stored");
// A malformed remembered brush keeps the default (never an error, never a panic).
let mut bad = Session::new();
bad.prefs.edit(|p| p.last_brush = json!({"brush": {"size": "huge"}}));
restore_brush(&mut bad);
assert_eq!(bad.tools.brush.size, Session::new().tools.brush.size);
}
#[test]
fn remembered_brushes_never_embed_large_bitmaps() {
use std::sync::Arc;
let big = GrayTile::from_fn(PREVIEW_SIDE + 1, 8, |_, _| 1.0);
assert!(ref_tip(&TipShape::Sampled(big)).is_none(), "a big sampled tip cannot be embedded");
let small = GrayTile::from_fn(8, 8, |_, _| 1.0);
assert!(ref_tip(&TipShape::Sampled(small.clone())).is_some(), "small tiles embed");
let stored =
StoredTile { key: "k".into(), width: 300, height: 200, preview: Arc::new(small), full: Some(Arc::new(GrayTile::from_fn(300, 200, |_, _| 1.0))) };
let t = ref_tip(&TipShape::Stored(stored)).expect("stored tips are remembered as references");
assert!(matches!(t, TipShape::Stored(r) if r.full.is_none()), "a loaded full bitmap is dropped");
assert!(ref_pattern(&Pattern::Tile(GrayTile::from_fn(PREVIEW_SIDE + 1, 4, |_, _| 1.0))).is_none());
}
/// Photoshop's Clear painting mode (#3439): a Brush or Pencil stroke in `"mode": "clear"` erases
/// with that brush, and History still names the tool rather than the Eraser.
#[test]
fn clear_mode_erases_with_the_brush_or_pencil() {
for (cmd, label) in [("paint.stroke", "Brush Tool"), ("paint.pencil", "Pencil")] {
let mut s = session(40, 20);
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute(cmd, json!({"points": [[2, 10], [38, 10]], "size": 6, "mode": "clear", "brush": {"hardness": 1.0}})).unwrap();
assert_eq!(rgba(&s, 20, 10)[3], 0.0, "{cmd}: cleared to transparency");
assert_eq!(rgba(&s, 20, 2), [1.0, 0.0, 0.0, 1.0], "{cmd}: untouched pixels stay");
assert_eq!(s.active().unwrap().history.undo_label(), Some(label), "{cmd}");
}
}
/// #4344: a foreground picked as CMYK 100/0/0/0 is painted into a CMYK document as those inks by
/// the Brush and the Pencil, not as whatever its sRGB stand-in converts back to (100/28/0/17).
#[test]
fn brush_and_pencil_paint_a_cmyk_pick_with_the_picked_inks() {
let cyan = [1.0f32, 0.0, 0.0, 0.0];
for cmd in ["paint.stroke", "paint.pencil"] {
let mut s = session(16, 16);
s.execute("image.mode.cmyk", json!({})).unwrap();
let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0));
s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap();
s.execute(cmd, json!({"points": [[8.0, 8.0]], "size": 6.0, "hardness": 1.0})).unwrap();
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
let got: Vec<f32> = surf.read_region(Rect::new(8, 8, 9, 9)).into_iter().take(4).collect();
assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{cmd}: {got:?}");
}
}