refactor(engine): move tests_*.rs to tests/<topic>.rs

This commit is contained in:
aiongg
2026-10-26 18:26:56 +00:00
parent 113c15c64f
commit 83eec43b18
170 changed files with 381 additions and 546 deletions
+517
View File
@@ -0,0 +1,517 @@
//! File → Export → Save for Web (Legacy): `document.exportForWeb`, its preview, the remembered
//! settings and the presets.
use serde_json::{Value, json};
use crate::*;
/// The default settings as JSON.
fn defaults() -> Value {
cmd::webexport::WebSettings::default().to_json()
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// A `w`×`h` pt document with unstroked art.
fn session(w: f64, h: f64) -> Session {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": w, "height": h}));
s.paint.stroke = vectorcraft_color::Paint::None;
s
}
fn rect(s: &mut Session, [x, y, w, h]: [f64; 4], color: &str) -> u64 {
let id = run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap();
run(s, "paint.setFill", json!({"color": color}));
id
}
/// 40×30: red on the left half, blue on the right (two colours, nothing transparent).
fn two_colours() -> Session {
let mut s = session(40.0, 30.0);
rect(&mut s, [0.0, 0.0, 20.0, 30.0], "#ff0000");
rect(&mut s, [20.0, 0.0, 20.0, 30.0], "#0000ff");
s
}
/// 64×64: an 8×8 grid of 64 different colours.
fn many_colours() -> Session {
let mut s = session(64.0, 64.0);
for i in 0..64 {
let (x, y) = ((i % 8) as f64 * 8.0, (i / 8) as f64 * 8.0);
rect(&mut s, [x, y, 8.0, 8.0], &format!("#{:02x}{:02x}{:02x}", i * 4, 255 - i * 3, (i * 37) % 256));
}
s
}
/// The files of an export without a path: (name, bytes).
fn files(v: &Value) -> Vec<(String, Vec<u8>)> {
v["files"]
.as_array()
.unwrap()
.iter()
.map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap()))
.collect()
}
/// The one file of an export.
fn one(s: &mut Session, p: Value) -> Vec<u8> {
let f = files(&run(s, "document.exportForWeb", p));
assert_eq!(f.len(), 1);
f[0].1.clone()
}
fn pixels(file: &[u8]) -> image::RgbaImage {
image::load_from_memory(file).unwrap().to_rgba8()
}
fn distinct(img: &image::RgbaImage) -> usize {
img.pixels().map(|p| p.0).collect::<std::collections::HashSet<_>>().len()
}
/// The transparent index of a GIF's graphic control extension, if it has one.
fn gif_transparent_index(gif: &[u8]) -> Option<u8> {
let i = gif.windows(3).position(|w| w == [0x21, 0xF9, 0x04])?;
(gif[i + 3] & 1 == 1).then_some(gif[i + 6])
}
#[test]
fn two_colours_give_a_two_colour_palette() {
let mut s = two_colours();
for format in ["gif", "png8"] {
let p = json!({"format": format, "colors": 2, "antiAlias": "none"});
let file = one(&mut s, p.clone());
let img = pixels(&file);
assert_eq!(img.dimensions(), (40, 30));
assert_eq!(distinct(&img), 2, "{format}");
assert_eq!(img.get_pixel(5, 5).0, [255, 0, 0, 255]);
assert_eq!(img.get_pixel(35, 5).0, [0, 0, 255, 255]);
let pv = run(&mut s, "document.exportForWeb.preview", p);
let colors: Vec<&str> = pv["colors"].as_array().unwrap().iter().map(|c| c["color"].as_str().unwrap()).collect();
assert_eq!(colors.len(), 2, "{format}: {colors:?}");
assert!(colors.contains(&"#ff0000") && colors.contains(&"#0000ff"));
assert!(pv["colors"].as_array().unwrap().iter().all(|c| c["webSafe"] == true && c["transparent"] == false));
}
// PNG-8 of two colours is one bit per pixel.
let png8 = one(&mut s, json!({"format": "png8", "colors": 2}));
assert_eq!(png8[24], 1);
}
#[test]
fn colours_cap_the_palette_at_16() {
let mut s = many_colours();
for format in ["gif", "png8"] {
for dither in ["none", "diffusion", "pattern", "noise"] {
for reduction in ["perceptual", "selective", "adaptive", "web"] {
let p = json!({"format": format, "colors": 16, "dither": dither, "reduction": reduction});
let img = pixels(&one(&mut s, p.clone()));
assert!(distinct(&img) <= 16, "{p}: {}", distinct(&img));
let pv = run(&mut s, "document.exportForWeb.preview", p.clone());
assert!(pv["colors"].as_array().unwrap().len() <= 16, "{p}");
}
}
}
// 128 colours keep all 64.
assert_eq!(distinct(&pixels(&one(&mut s, json!({"format": "png8", "colors": 128})))), 64);
}
#[test]
fn gif_transparency_index_and_map_to_transparent() {
let mut s = session(40.0, 30.0);
rect(&mut s, [10.0, 5.0, 10.0, 20.0], "#ff0000");
rect(&mut s, [20.0, 5.0, 10.0, 20.0], "#00ff00");
let gif = one(&mut s, json!({"format": "gif", "antiAlias": "none"}));
assert_eq!(&gif[..6], b"GIF89a");
assert_eq!(gif_transparent_index(&gif), Some(0), "the transparent entry is the first");
let img = pixels(&gif);
assert_eq!(img.get_pixel(1, 1)[3], 0);
assert_eq!(img.get_pixel(15, 15).0, [255, 0, 0, 255]);
// Without transparency: no transparent index; the background is the matte.
let opaque = one(&mut s, json!({"format": "gif", "transparency": false, "matte": "#000000"}));
assert_eq!(gif_transparent_index(&opaque), None);
assert_eq!(pixels(&opaque).get_pixel(1, 1).0, [0, 0, 0, 255]);
// Map to Transparent: red goes, green stays.
let p = json!({"format": "gif", "antiAlias": "none", "colorTable": {"transparent": ["#ff0000"]}});
let mapped = pixels(&one(&mut s, p.clone()));
assert_eq!(mapped.get_pixel(15, 15)[3], 0);
assert_eq!(mapped.get_pixel(25, 15).0, [0, 255, 0, 255]);
let pv = run(&mut s, "document.exportForWeb.preview", p);
assert_eq!(pv["mappedToTransparent"], json!(["#ff0000"]));
assert!(pv["colors"].as_array().unwrap().iter().all(|c| c["source"] != "#ff0000"));
// An opaque image gets a transparent index for a mapped colour.
let gif = one(&mut s, json!({"format": "gif", "transparency": false, "colorTable": {"transparent": ["#00ff00"]}}));
let i = gif_transparent_index(&gif).unwrap();
assert_eq!(pixels(&gif).get_pixel(25, 15)[3], 0, "index {i}");
}
#[test]
fn jpeg_size_grows_with_quality() {
let mut s = many_colours();
run(&mut s, "shape.ellipse", json!({"x": 5, "y": 7, "width": 41, "height": 33}));
let sizes: Vec<usize> = [0, 20, 40, 60, 80, 100].iter().map(|q| one(&mut s, json!({"format": "jpg", "quality": q})).len()).collect();
assert!(sizes.windows(2).all(|w| w[0] <= w[1]), "{sizes:?}");
assert!(sizes[0] < sizes[5], "{sizes:?}");
let jpg = one(&mut s, json!({"format": "jpeg", "quality": 80}));
assert_eq!(&jpg[..2], &[0xFF, 0xD8]);
// Transparent areas are on the matte.
let mut t = session(40.0, 30.0);
rect(&mut t, [0.0, 0.0, 10.0, 10.0], "#000000");
let yellow = |p: [u8; 4]| p[0] > 240 && p[1] > 240 && p[2] < 20;
let img = pixels(&one(&mut t, json!({"format": "jpg", "quality": 100, "matte": "#ffff00", "optimized": false})));
assert!(yellow(img.get_pixel(30, 20).0), "{:?}", img.get_pixel(30, 20));
// The preview shows the pixels of an optimised file (decoded from a baseline twin: the
// `image` crate misreads some optimised Huffman tables).
let st = cmd::webexport::WebSettings { format: cmd::webexport::WebFormat::Jpg, quality: 90, matte: "#ffff00".into(), ..Default::default() };
let doc = &t.doc().unwrap().doc;
let r = cmd::webexport::render(doc, &st).unwrap();
let o = cmd::webexport::preview_image(doc, &r.rgba, r.width, r.height, &st).unwrap();
assert_eq!(o.bytes, cmd::webexport::optimize(doc, &r.rgba, r.width, r.height, &st).unwrap().bytes, "the file is the one Save writes");
let (px, w, h) = cmd::webexport::decode(&o).unwrap();
assert_eq!((w, h), (40, 30));
let at = (20 * 40 + 30) * 4;
assert!(yellow([px[at], px[at + 1], px[at + 2], px[at + 3]]), "{:?}", &px[at..at + 4]);
// Progressive frames, and the sRGB profile when asked.
let prog = one(&mut t, json!({"format": "jpg", "progressive": true, "embedProfile": true}));
assert!(prog.windows(2).any(|w| w == [0xFF, 0xC2]));
assert!(prog.windows(12).any(|w| w == b"ICC_PROFILE\0"));
}
#[test]
fn preview_size_is_the_written_size() {
let mut s = many_colours();
for p in [
json!({"format": "gif", "colors": 32, "lossy": 40}),
json!({"format": "png8", "colors": 64, "webSnap": 50, "interlaced": true}),
json!({"format": "png24", "transparency": false}),
json!({"format": "jpg", "quality": 35, "metadata": "all"}),
json!({"format": "gif", "percent": 50, "colorTable": {"sort": "luminance"}}),
] {
let file = one(&mut s, p.clone());
let pv = run(&mut s, "document.exportForWeb.preview", merge(&p, json!({"image": true})));
assert_eq!(pv["bytes"].as_u64().unwrap() as usize, file.len(), "{p}");
assert_eq!(vectorcraft_format::base64_decode(pv["dataBase64"].as_str().unwrap()).unwrap(), file, "{p}");
let img = pixels(&file);
assert_eq!((pv["width"].as_u64().unwrap() as u32, pv["height"].as_u64().unwrap() as u32), img.dimensions());
let seconds = pv["seconds"].as_f64().unwrap();
assert!((seconds - file.len() as f64 * 8.0 / 56_600.0).abs() < 1e-9, "at 56.6 kbps by default");
}
let pv = run(&mut s, "document.exportForWeb.preview", json!({"kbps": 1000}));
assert!((pv["seconds"].as_f64().unwrap() * 125_000.0 - pv["bytes"].as_f64().unwrap()).abs() < 1e-6);
assert!(s.execute("document.exportForWeb.preview", &json!({"kbps": 0})).is_err());
}
fn merge(a: &Value, b: Value) -> Value {
let mut a = a.clone();
if let (Some(o), Value::Object(b)) = (a.as_object_mut(), b) {
o.extend(b);
}
a
}
#[test]
fn slices_give_one_image_each_and_a_page() {
let mut s = session(300.0, 200.0);
rect(&mut s, [0.0, 0.0, 300.0, 200.0], "#336699");
let a = rect(&mut s, [20.0, 20.0, 100.0, 60.0], "#ff8800");
run(&mut s, "object.slice.make", json!({"ids": [a]}));
run(&mut s, "object.slice.options", json!({"name": "logo", "url": "https://example.com/", "alt": "Logo <1>", "target": "_blank"}));
let b = run(&mut s, "object.slice.create", json!({"x": 150, "y": 100, "width": 100, "height": 50}))["id"].as_u64().unwrap();
let layout = run(&mut s, "slice.list", json!({}))["slices"].as_array().unwrap().clone();
let n = layout.len();
assert!(n > 2, "auto slices fill the rest: {n}");
let r = run(&mut s, "document.exportForWeb", json!({"format": "gif", "output": "html"}));
let f = files(&r);
assert_eq!(f.len(), n + 1, "N images plus the page");
assert_eq!(f[0].0, "Untitled-1.html");
assert!(f[1..].iter().all(|(name, bytes)| name.starts_with("images/") && name.ends_with(".gif") && bytes.starts_with(b"GIF")));
assert!(f.iter().any(|(name, _)| name == "images/logo.gif"));
let html = String::from_utf8(f[0].1.clone()).unwrap();
assert_eq!(html.matches("<img ").count(), n);
assert!(html.contains("<a href=\"https://example.com/\" target=\"_blank\""));
assert!(html.contains("alt=\"Logo &lt;1&gt;\""));
assert!(html.contains("width: 300px; height: 200px;"));
// Each slice image has its slice's pixel size.
let logo = f.iter().find(|(name, _)| name == "images/logo.gif").unwrap();
assert_eq!(pixels(&logo.1).dimensions(), (100, 60));
// The images add up to the whole: every pixel is in one slice.
let area: u64 = f[1..].iter().map(|(_, b)| pixels(b).dimensions()).map(|(w, h)| u64::from(w) * u64::from(h)).sum();
assert_eq!(area, 300 * 200);
// Images only: no page.
assert_eq!(files(&run(&mut s, "document.exportForWeb", json!({}))).len(), n);
// A No Image slice is text on the page, not a file.
run(&mut s, "object.slice.select", json!({"slices": [b]}));
run(&mut s, "object.slice.options", json!({"kind": "noImage", "text": "<b>Hi</b>", "background": "#ffffff", "hAlign": "center"}));
let f = files(&run(&mut s, "document.exportForWeb", json!({"output": "html"})));
assert_eq!(f.len(), n);
let html = String::from_utf8(f[0].1.clone()).unwrap();
assert!(html.contains("<b>Hi</b>") && html.contains("background: #ffffff;") && html.contains("justify-content: center"));
// Selected slices only.
run(&mut s, "object.slice.select", json!({"slices": [a]}));
let f = files(&run(&mut s, "document.exportForWeb", json!({"slices": "selected"})));
assert_eq!(f.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["images/logo.gif"]);
run(&mut s, "object.slice.select", json!({}));
assert!(s.execute("document.exportForWeb", &json!({"slices": "selected"})).is_err());
// No slices: one image.
assert_eq!(files(&run(&mut s, "document.exportForWeb", json!({"slices": "none"}))).len(), 1);
assert_eq!(run(&mut s, "document.exportForWeb.preview", json!({}))["slices"], n - 1);
let one_slice = run(&mut s, "document.exportForWeb.preview", json!({"slice": layout[0]["number"]}));
assert_eq!((one_slice["width"].as_f64(), one_slice["height"].as_f64()), (layout[0]["width"].as_f64(), layout[0]["height"].as_f64()));
}
/// Save for Web writes the image of a slice named after a Windows device (`aux`, `NUL`, `COM1`…)
/// with a `-` after the device name, on every platform.
#[test]
fn a_slice_named_after_a_windows_device_gets_a_dash() {
let mut s = session(300.0, 200.0);
let a = rect(&mut s, [20.0, 20.0, 100.0, 60.0], "#ff8800");
run(&mut s, "object.slice.make", json!({"ids": [a]}));
run(&mut s, "object.slice.options", json!({"name": "aux"}));
let b = run(&mut s, "object.slice.create", json!({"x": 150, "y": 100, "width": 100, "height": 50}))["id"].as_u64().unwrap();
run(&mut s, "object.slice.select", json!({"slices": [b]}));
run(&mut s, "object.slice.options", json!({"name": "aux-"}));
run(&mut s, "object.slice.select", json!({"slices": [a, b]}));
let mut names: Vec<String> = files(&run(&mut s, "document.exportForWeb", json!({"slices": "selected"}))).into_iter().map(|(n, _)| n).collect();
names.sort();
assert_eq!(names, ["images/aux- 2.gif", "images/aux-.gif"]);
}
/// Without a path the files are named after the document's title, with the characters Windows does
/// not allow in a file name as `-` and a `-` after a Windows device name, on every platform.
#[test]
fn files_named_after_the_title_follow_windows_naming_rules() {
for (title, page, image) in [("a|b?c*\"<d>", "a-b-c---d-.html", "images/a-b-c---d-.gif"), ("nul", "nul-.html", "images/nul-.gif")] {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 40, "height": 30, "name": title}));
rect(&mut s, [0.0, 0.0, 20.0, 30.0], "#ff0000");
let names: Vec<String> = files(&run(&mut s, "document.exportForWeb", json!({"output": "html"}))).into_iter().map(|(n, _)| n).collect();
assert_eq!(names, [page, image], "{title}");
}
}
#[test]
fn files_are_written_beside_the_path() {
let dir = vectorcraft_testkit::temp_dir("webexport");
let mut s = two_colours();
let path = dir.join("art.gif").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": path}));
assert_eq!(r["files"], json!([path]));
assert!(std::fs::read(&path).unwrap().starts_with(b"GIF89a"));
let page = dir.join("page.html").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html", "format": "png24"}));
assert_eq!(r["path"], json!(page));
assert!(std::fs::read_to_string(&page).unwrap().contains("src=\"images/page.png\""));
assert!(dir.join("images").join("page.png").exists());
assert_eq!(r["bytes"].as_u64().unwrap(), std::fs::metadata(&page).unwrap().len() + std::fs::metadata(dir.join("images/page.png")).unwrap().len());
}
/// A 64×32 PNG whose header reads (so it places at its size) but whose pixel data is cut short.
fn cut_png() -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
png.truncate(idat + 8);
png
}
#[test]
fn an_export_says_when_an_image_could_not_be_decoded() {
let undecoded =
|r: &Value| r["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))));
let mut s = two_colours();
let r = run(&mut s, "document.exportForWeb", json!({}));
assert_eq!(r["warnings"], json!([]), "{r}");
assert_eq!(run(&mut s, "document.exportForWeb.preview", json!({}))["warnings"], json!([]));
// The renderer can't decode the placed image and draws nothing in its place.
run(&mut s, "file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut_png())}));
for format in ["gif", "jpg", "png8", "png24"] {
let r = run(&mut s, "document.exportForWeb", json!({"format": format}));
assert!(undecoded(&r), "{format}: {}", r["warnings"]);
let r = run(&mut s, "document.exportForWeb.preview", json!({"format": format}));
assert!(undecoded(&r), "{format} preview: {}", r["warnings"]);
}
let dir = vectorcraft_testkit::temp_dir("webexport-undecoded");
let page = dir.join("page.html").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html"}));
assert!(undecoded(&r), "{r}");
assert!(dir.join("images").join("page.gif").exists(), "the files are still written");
}
#[test]
fn explicit_artboards_and_unclipped_art_keep_their_pixels_and_dimensions() {
let mut s = session(60.0, 40.0);
rect(&mut s, [0.0, 0.0, 60.0, 40.0], "#ff0000");
run(&mut s, "artboard.new", json!({"x": 100, "y": 0, "width": 32, "height": 24}));
rect(&mut s, [100.0, 0.0, 32.0, 24.0], "#0000ff");
for (params, size, color) in [
(json!({"format": "png24", "artboard": 0}), (60, 40), [255, 0, 0, 255]),
(json!({"format": "png24", "artboard": 1}), (32, 24), [0, 0, 255, 255]),
(json!({"format": "png24"}), (60, 40), [255, 0, 0, 255]),
] {
let image = pixels(&one(&mut s, params));
assert_eq!(image.dimensions(), size);
assert_eq!(image.get_pixel(8, 8).0, color);
}
let full = pixels(&one(&mut s, json!({"format": "png24", "artboard": 1, "clipToArtboard": false})));
assert_eq!(full.dimensions(), (132, 40));
assert_eq!(full.get_pixel(8, 8).0, [255, 0, 0, 255]);
assert_eq!(full.get_pixel(108, 8).0, [0, 0, 255, 255]);
assert_eq!(full.get_pixel(80, 8).0, [0, 0, 0, 0]);
}
#[test]
fn image_size_region_and_metadata() {
let mut s = two_colours();
let size = |s: &mut Session, p: Value| pixels(&one(s, p)).dimensions();
assert_eq!(size(&mut s, json!({"width": 80})), (80, 60));
assert_eq!(size(&mut s, json!({"height": 15})), (20, 15));
assert_eq!(size(&mut s, json!({"percent": 150})), (60, 45));
// The dialog's fields send decimal numbers: rounded to whole pixels.
assert_eq!(size(&mut s, json!({"width": 80.0})), (80, 60));
assert_eq!(size(&mut s, json!({"height": 14.6})), (20, 15));
for bad in [
json!({"percent": 0}),
json!({"width": 0}),
json!({"width": 0.2}),
json!({"width": -5}),
json!({"height": 1e300}),
json!({"height": "tall"}),
json!({"format": "bmp"}),
json!({"colors": "many"}),
json!({"matte": "plaid"}),
json!({"colorTable": {"locked": ["nope"]}}),
json!({"artboard": 3}),
json!({"preset": "Nope"}),
] {
assert!(s.execute("document.exportForWeb", &bad).is_err(), "{bad}");
}
// Clip to Artboard off: the art's bounds.
let mut t = session(100.0, 100.0);
rect(&mut t, [10.0, 20.0, 30.0, 15.0], "#000000");
assert_eq!(size(&mut t, json!({"clipToArtboard": false})), (30, 15));
assert_eq!(size(&mut t, json!({})), (100, 100));
// File Info: the copyright by default, everything with all, nothing with none.
run(&mut s, "file.info", json!({"copyrightNotice": "(c) Someone", "author": "Ann", "description": "Flags"}));
let png = one(&mut s, json!({"format": "png24"}));
let has = |f: &[u8], t: &str| f.windows(t.len()).any(|w| w == t.as_bytes());
assert!(has(&png, "(c) Someone") && !has(&png, "Ann"));
assert!(has(&png, "sRGB"), "converted to sRGB");
assert!(!has(&one(&mut s, json!({"format": "png24", "convertToSrgb": false})), "sRGB"));
let all = one(&mut s, json!({"format": "png8", "metadata": "all"}));
assert!(has(&all, "Ann") && has(&all, "Flags"));
assert!(has(&one(&mut s, json!({"format": "png24", "metadata": "contact"})), "Ann"));
assert!(!has(&one(&mut s, json!({"format": "gif", "metadata": "none"})), "Someone"));
assert!(has(&one(&mut s, json!({"format": "gif"})), "Copyright: (c) Someone"));
assert!(has(&one(&mut s, json!({"format": "jpg"})), "Copyright: (c) Someone"));
}
#[test]
fn colour_table_lock_web_shift_snap_and_sort() {
let mut s = session(30.0, 10.0);
rect(&mut s, [0.0, 0.0, 10.0, 10.0], "#123456");
rect(&mut s, [10.0, 0.0, 10.0, 10.0], "#fe0101");
rect(&mut s, [20.0, 0.0, 10.0, 10.0], "#808080");
let colors = |s: &mut Session, p: Value| -> Vec<Value> { run(s, "document.exportForWeb.preview", p)["colors"].as_array().unwrap().clone() };
let base = colors(&mut s, json!({}));
assert_eq!(base.len(), 3);
assert!(base.iter().all(|c| c["color"] == c["source"]));
// Web Shift one colour by its source.
let shifted = colors(&mut s, json!({"colorTable": {"webShift": ["#123456"]}}));
let c = shifted.iter().find(|c| c["source"] == "#123456").unwrap();
assert_eq!((c["color"].as_str(), c["webShifted"].as_bool(), c["webSafe"].as_bool()), (Some("#003366"), Some(true), Some(true)));
// Web Snap: near colours snap, far ones don't; locked ones never do.
let snapped = colors(&mut s, json!({"webSnap": 5}));
assert!(snapped.iter().any(|c| c["source"] == "#fe0101" && c["color"] == "#ff0000"));
assert!(snapped.iter().any(|c| c["source"] == "#123456" && c["color"] == "#123456"));
let locked = colors(&mut s, json!({"webSnap": 100, "colorTable": {"locked": ["#fe0101"]}}));
let c = locked.iter().find(|c| c["source"] == "#fe0101").unwrap();
assert_eq!((c["color"].as_str(), c["locked"].as_bool()), (Some("#fe0101"), Some(true)));
// Locked colours survive fewer colours.
let two = colors(&mut s, json!({"colors": 2, "dither": "none", "colorTable": {"locked": ["#808080"]}}));
assert!(two.len() <= 2 && two.iter().any(|c| c["color"] == "#808080"), "{two:?}");
// Sorting by luminance.
let sorted = colors(&mut s, json!({"colorTable": {"sort": "luminance"}}));
let names: Vec<&str> = sorted.iter().map(|c| c["color"].as_str().unwrap()).collect();
assert_eq!(names, ["#123456", "#fe0101", "#808080"]);
// Lossy makes smaller GIFs of busy art.
let mut m = many_colours();
let plain = one(&mut m, json!({"colors": 32})).len();
let lossy = one(&mut m, json!({"colors": 32, "lossy": 100})).len();
assert!(lossy < plain, "{lossy} vs {plain}");
}
#[test]
fn presets_and_remembered_settings() {
let mut s = two_colours();
let list = run(&mut s, "webExport.presets.list", json!({}));
let names: Vec<&str> = list["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
assert!(names.len() >= 6 && list["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
// A built-in preset as a starting point.
let jpg = one(&mut s, json!({"preset": "JPEG, quality 80"}));
assert_eq!(&jpg[..2], &[0xFF, 0xD8]);
assert!(one(&mut s, json!({"preset": "jpeg, QUALITY 80", "quality": 10})).len() < jpg.len(), "keys win over the preset");
let r = run(&mut s, "webExport.presets.save", json!({"name": "Mine", "format": "png8", "colors": 4}));
assert_eq!((r["created"].as_bool(), r["settings"]["colors"].as_u64()), (Some(true), Some(4)));
let r = run(&mut s, "webExport.presets.save", json!({"name": "mine", "settings": {"interlaced": true}}));
assert_eq!((r["created"].as_bool(), r["settings"]["colors"].as_u64(), r["settings"]["interlaced"].as_bool()), (Some(false), Some(4), Some(true)));
let png = one(&mut s, json!({"preset": "Mine"}));
assert_eq!((png[25], png[28]), (3, 1), "indexed and interlaced");
run(&mut s, "webExport.presets.save", json!({"name": "Mine", "newName": "Ours"}));
assert!(s.execute("webExport.presets.save", &json!({"name": "PNG-24"})).is_err(), "built in");
assert!(s.execute("webExport.presets.save", &json!({"name": "Ours", "newName": "PNG-24"})).is_err(), "taken");
assert!(s.execute("webExport.presets.save", &json!({})).is_err());
assert!(s.execute("webExport.presets.delete", &json!({"name": "PNG-24"})).is_err());
// Saved presets are preferences (not Preferences dialog keys) and survive a reset.
let saved: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
assert_eq!(saved, s.prefs, "round trip");
assert_eq!(s.prefs.to_json()["webExportPresets"][0]["name"], "Ours");
assert!(Prefs::default().to_json().get("webExportPresets").is_none() && Prefs::default().to_json().get("webExportSettings").is_none());
run(&mut s, "prefs.reset", json!({}));
assert_eq!(s.prefs.web_export_presets.len(), 1);
assert_eq!(run(&mut s, "webExport.presets.delete", json!({"name": "ours"}))["deleted"], "Ours");
assert!(s.execute("webExport.presets.delete", &json!({"name": "Ours"})).is_err());
// The dialog's remembered settings.
assert_eq!(run(&mut s, "webExport.settings", json!({}))["settings"], defaults());
assert!(s.prefs.web_export_settings.is_none(), "reading stores nothing");
run(&mut s, "webExport.settings", json!({"format": "jpg", "quality": 30}));
let r = run(&mut s, "webExport.settings", json!({"progressive": true}));
assert_eq!(
(r["settings"]["format"].as_str(), r["settings"]["quality"].as_u64(), r["settings"]["progressive"].as_bool()),
(Some("jpg"), Some(30), Some(true))
);
assert_eq!(run(&mut s, "webExport.settings", json!({"preset": "PNG-24"}))["settings"]["format"], "png24");
assert_eq!(run(&mut s, "webExport.settings", json!({"reset": true}))["settings"], defaults());
assert!(s.execute("webExport.settings", &json!({"quality": 500})).is_err());
}
#[test]
fn setting_keys_are_their_json_values() {
use cmd::webexport::{SliceScope, WebFormat, WebMetadata, WebOutput};
for f in WebFormat::ALL {
assert_eq!(serde_json::to_value(f).unwrap(), json!(f.id()));
}
for m in WebMetadata::ALL {
assert_eq!(serde_json::to_value(m).unwrap(), json!(m.key()));
}
for s in SliceScope::ALL {
assert_eq!(serde_json::to_value(s).unwrap(), json!(s.key()));
}
for o in WebOutput::ALL {
assert_eq!(serde_json::to_value(o).unwrap(), json!(o.key()));
}
// Every setting round-trips through JSON (presets, the remembered settings).
let s = Session::new();
let custom = s
.web_settings(
&json!({"format": "png8", "reduction": "web", "dither": "pattern", "antiAlias": "type", "colorTable": {"sort": "hue"}, "percent": 50}),
)
.unwrap();
assert_eq!(serde_json::from_value::<cmd::webexport::WebSettings>(custom.to_json()).unwrap(), custom);
}