Files
vectorcraft/crates/engine/src/tests/compare.rs
T

277 lines
14 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.
//! `document.compare`: an artboard rendered at an image's size, as a PNG export draws it, and
//! measured against the image (RMSE, the pixels over a threshold, a difference image), with the
//! image and the render flattened on one background; images of other proportions, files that
//! can't be read and paths outside the automation roots are errors.
use serde_json::{Value, json};
use crate::Session;
/// A 40 × 20 pt document with a black 10 × 10 pt square at (10, 5), no stroke.
fn session() -> (Session, Value) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap();
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap()["id"].clone();
(s, id)
}
fn path_in(dir: &std::path::Path, name: &str) -> String {
dir.join(name).to_string_lossy().into_owned()
}
/// The artboard exported as a PNG at 2 pixels a point on white.
fn export(s: &mut Session, path: &str) {
s.execute("document.export", &json!({"path": path, "format": "png", "scale": 2, "background": "white"})).unwrap();
}
fn compare(s: &mut Session, p: Value) -> Value {
s.execute("document.compare", &p).unwrap_or_else(|e| panic!("document.compare {p}: {e}"))
}
fn error(s: &mut Session, p: Value) -> String {
s.execute("document.compare", &p).map(|v| panic!("document.compare {p} answered {v}")).unwrap_err().to_string()
}
#[test]
fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() {
let dir = vectorcraft_testkit::temp_dir("compare-moved");
let png = path_in(&dir, "art.png");
let (mut s, id) = session();
export(&mut s, &png);
let before = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len());
let r = compare(&mut s, json!({"path": png}));
assert_eq!(r, json!({"width": 80, "height": 40, "rmse": 0.0, "over": 0, "total": 3200, "threshold": 0.08}));
assert_eq!((s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()), before, "comparing doesn't edit the document");
// 4 pt is 8 pixels: 8 columns of the square's 20 rows turn white and 8 turn black, every
// channel by 255.
s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap();
let r = compare(&mut s, json!({"path": png}));
let over = 2 * 8 * 20;
assert_eq!((r["over"].as_u64(), r["total"].as_u64()), (Some(over), Some(3200)), "{r}");
let rmse = ((over as f64 / 3200.0).sqrt() * 1e6).round() / 1e6;
assert_eq!(r["rmse"].as_f64(), Some(rmse), "{r}");
// Above every difference, nothing is over.
assert_eq!(compare(&mut s, json!({"path": png, "threshold": 1}))["over"], json!(0));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_image_and_the_render_are_flattened_on_the_background() {
let dir = vectorcraft_testkit::temp_dir("compare-background");
let png = path_in(&dir, "clear.png");
// Transparent everywhere, with red in the color channels that the alpha hides.
image::RgbaImage::from_pixel(80, 40, image::Rgba([255, 0, 0, 0])).save(&png).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap();
// Nothing drawn: both are the background alone.
for background in ["#808080", "#ffffff"] {
let r = compare(&mut s, json!({"path": png, "background": background}));
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(0.0), Some(0)), "{background}: {r}");
}
// A black left half: 128 from gray in every channel of half the pixels, 255 from white.
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let r = compare(&mut s, json!({"path": png, "background": "#808080"}));
let rmse = |d: f64| ((0.5f64).sqrt() * d / 255.0 * 1e6).round() / 1e6;
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(128.0)), Some(1600)), "{r}");
let r = compare(&mut s, json!({"path": png}));
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(255.0)), Some(1600)), "{r}");
// A threshold just above 128/255 leaves the gray differences out.
assert_eq!(compare(&mut s, json!({"path": png, "background": "#808080", "threshold": 0.51}))["over"], json!(0));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() {
let dir = vectorcraft_testkit::temp_dir("compare-size");
let (mut s, _) = session();
let square = path_in(&dir, "square.png");
image::RgbaImage::from_pixel(30, 30, image::Rgba([255; 4])).save(&square).unwrap();
let e = error(&mut s, json!({"path": square}));
assert!(e.contains("30 × 30 pixels") && e.contains("30 × 15 pixels"), "{e}");
let taller = path_in(&dir, "taller.png");
image::RgbaImage::from_pixel(80, 41, image::Rgba([255; 4])).save(&taller).unwrap();
let r = compare(&mut s, json!({"path": taller}));
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["total"].as_u64()), (Some(80), Some(41), Some(3280)), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_export_whose_height_was_rounded_compares_equal() {
let dir = vectorcraft_testkit::temp_dir("compare-rounded");
let png = path_in(&dir, "art.png");
// 20.25 pt at 2 pixels a point is 40.5 rows, which the export rounds to 41.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 20.25})).unwrap();
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap();
export(&mut s, &png);
let r = compare(&mut s, json!({"path": png}));
assert_eq!(r, json!({"width": 80, "height": 41, "rmse": 0.0, "over": 0, "total": 3280, "threshold": 0.08}));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() {
use crate::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write};
let dir = Folder::new("compare-linked");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
place(&mut s, &pic);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
// The file changes after the save: the reopened document shows the red preview saved for it,
// and a PNG export reads the blue file.
write(&pic, &png(600, 300, BLUE));
open(&mut s, &doc_path);
let out = dir.file("export.png");
s.execute("document.export", &json!({"path": out, "format": "png", "background": "white"})).unwrap();
let r = compare(&mut s, json!({"path": out}));
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64(), r.get("warnings")), (Some(0.0), Some(0), None), "{r}");
// Without the file the preview is drawn, and the reply says so.
std::fs::remove_file(&pic).unwrap();
let r = compare(&mut s, json!({"path": out}));
assert_eq!(r["over"].as_u64(), r["total"].as_u64(), "{r}");
let warnings = r["warnings"].as_array().unwrap();
assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("1 linked image output as its preview"), "{r}");
// With type that doesn't fit its frame as well, the warnings come in the export's order.
let overset =
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}})).unwrap();
assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}");
let exported = s.execute("document.export", &json!({"path": dir.file("now.png"), "format": "png", "background": "white"})).unwrap();
let r = compare(&mut s, json!({"path": out}));
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(2), "{r}");
assert_eq!(r["warnings"], exported["warnings"], "{r}");
}
#[test]
fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does() {
let dir = vectorcraft_testkit::temp_dir("compare-warnings");
let png = path_in(&dir, "art.png");
let (mut s, _) = session();
export(&mut s, &png);
assert_eq!(compare(&mut s, json!({"path": png})).get("warnings"), None);
crate::tests_menucmds::place_undecodable_png(&mut s);
s.execute("text.create", &json!({"x": 2, "y": 18, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let overset = s.execute("text.create", &json!({"x": 22, "y": 2, "text": "Ab\nCd\nEf", "size": 6, "area": {"width": 15, "height": 8}})).unwrap();
assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}");
let exported = s.execute("document.export", &json!({"path": path_in(&dir, "now.png"), "format": "png", "scale": 2})).unwrap();
let r = compare(&mut s, json!({"path": png}));
assert_eq!(r["warnings"], exported["warnings"], "{r}");
let warnings = r["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{r}");
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("Nonexistent Sans Probe")), "{r}");
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("doesn't fit its frame")), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn diff_path_writes_each_pixels_differences() {
let dir = vectorcraft_testkit::temp_dir("compare-diff");
let png = path_in(&dir, "art.png");
let diff = path_in(&dir, "diff.png");
let (mut s, id) = session();
export(&mut s, &png);
s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap();
let r = compare(&mut s, json!({"path": png, "diffPath": diff}));
assert_eq!(r["diffPath"], json!(diff));
let img = image::open(&diff).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (80, 40));
// The square spanned pixels 20–39 and now spans 28–47, rows 10–29.
for (x, y, want) in [(24, 20, 255), (44, 20, 255), (30, 20, 0), (24, 5, 0), (60, 20, 0)] {
assert_eq!(img.get_pixel(x, y).0, [want, want, want, 255], "({x}, {y})");
}
let changed = img.pixels().filter(|p| p.0[0] != 0).count();
assert_eq!(changed as u64, r["over"].as_u64().unwrap());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn missing_unreadable_and_malformed_inputs_are_errors() {
let dir = vectorcraft_testkit::temp_dir("compare-errors");
let (mut s, _) = session();
let png = path_in(&dir, "art.png");
export(&mut s, &png);
let missing = path_in(&dir, "missing.png");
assert!(error(&mut s, json!({"path": missing})).contains(&missing));
let text = path_in(&dir, "notes.png");
std::fs::write(&text, "not an image\n").unwrap();
assert!(error(&mut s, json!({"path": text})).contains("not a PNG, JPEG, WebP, TIFF or BMP image"));
let gif = path_in(&dir, "art.gif");
image::RgbaImage::from_pixel(80, 40, image::Rgba([255; 4])).save(&gif).unwrap();
assert!(error(&mut s, json!({"path": gif})).contains("not a PNG, JPEG, WebP, TIFF or BMP image"));
for (p, want) in [
(json!({}), "missing `path`"),
(json!({"path": png, "artboard": 3}), "no artboard 3"),
(json!({"path": png, "threshold": 2}), "`threshold` must be a number from 0 to 1"),
(json!({"path": png, "threshold": "low"}), "`threshold` must be a number from 0 to 1"),
(json!({"path": png, "background": "transparent"}), "a color such as"),
(json!({"path": png, "diffPath": 5}), "`diffPath` must be a string"),
] {
let e = error(&mut s, p.clone());
assert!(e.contains(want), "{p}: {e}");
}
let _ = std::fs::remove_dir_all(dir);
}
/// A planar TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a strip
/// each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() {
let dir = vectorcraft_testkit::temp_dir("compare-strips");
let (mut s, _) = session();
let plane = |v: u16| vec![v; 6];
let channels = [plane(200), plane(40), plane(10), plane(0), plane(90)];
let t = vectorcraft_testkit::tiff::Tiff {
width: 3,
height: 2,
photometric: 5,
bits: 8,
extra: &[0],
planar: true,
ppi: Some(150),
channels: &channels,
};
let mut file = vectorcraft_testkit::tiff::tiff(&t);
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
// eighth, at offset 102).
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
file[102..106].copy_from_slice(&1u32.to_le_bytes());
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
let tif = path_in(&dir, "strips.tif");
std::fs::write(&tif, &file).unwrap();
let e = error(&mut s, json!({"path": tif}));
assert_eq!(e, "invalid parameters for `document.compare`: an image of 3 × 2147483649 pixels is too large to draw");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_image_and_the_difference_image_stay_inside_the_automation_roots() {
let dir = vectorcraft_testkit::temp_dir("compare-roots");
for d in ["in", "out", "elsewhere"] {
std::fs::create_dir_all(dir.join(d)).unwrap();
}
let (mut s, _) = session();
let inside = path_in(&dir.join("in"), "art.png");
let outside = path_in(&dir.join("elsewhere"), "art.png");
export(&mut s, &inside);
export(&mut s, &outside);
let roots = crate::file_access::AutomationRoots::new(&[&dir.join("in")], &[&dir.join("out")]).unwrap().unwrap();
let mut run = |p: Value| crate::file_access::confine(Some(&roots), || s.execute("document.compare", &p));
assert!(run(json!({"path": inside})).is_ok());
let e = run(json!({"path": outside})).unwrap_err().to_string();
assert!(e.contains("outside the read root"), "{e}");
let stray = path_in(&dir.join("elsewhere"), "diff.png");
let e = run(json!({"path": inside, "diffPath": stray})).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{e}");
assert!(!std::path::Path::new(&stray).exists());
let kept = path_in(&dir.join("out"), "diff.png");
assert_eq!(run(json!({"path": inside, "diffPath": kept})).unwrap()["diffPath"], json!(kept));
let _ = std::fs::remove_dir_all(dir);
}