Files
vectorcraft/crates/engine/src/tests_assets.rs
T
Fabio William Conceição b04e3ac366 G12: Collect for Export from Layers panel and layer ids
Layers ≡ and row menus collect art via assets.add; a layer id expands to the art under it (including sublayers) so MCP and the panel match Object › Collect for Export.
2026-10-16 06:16:36 +07:00

353 lines
18 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.
//! Asset Export: collecting art as assets (single and multiple), assets following their art
//! (moves, deletions), names, undo, the native round trip, the shared export settings and
//! exporting assets alone, cropped to their art. Art Optimized PNG exports are supersampled
//! so abutting shapes leave no ghost seam (#983 / #1037).
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// Unstroked art: visual bounds are the geometry.
s.paint.stroke = Paint::None;
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 {
run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()
}
fn assets(s: &mut Session) -> Vec<Value> {
run(s, "assets.list", json!({}))["assets"].as_array().unwrap().clone()
}
fn names(s: &mut Session) -> Vec<String> {
assets(s).iter().map(|a| a["name"].as_str().unwrap().to_string()).collect()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// `(name, bytes)` of each file an export returned.
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
r["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()
}
#[test]
fn multiple_gives_an_asset_per_object_and_single_one_of_them_all() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "select.set", json!({"ids": [a, b]}));
let r = run(&mut s, "assets.add", json!({}));
assert_eq!(r["added"], 2);
let list = assets(&mut s);
assert_eq!(list.len(), 2);
assert_eq!((list[0]["nodes"].clone(), list[1]["nodes"].clone()), (json!([a]), json!([b])));
assert_eq!(r["assets"], json!([list[0]["id"], list[1]["id"]]));
let single = run(&mut s, "assets.add", json!({"multiple": false}));
assert_eq!(single["added"], 1);
let list = assets(&mut s);
assert_eq!(list.len(), 3);
assert_eq!(list[2]["nodes"], json!([a, b]), "one asset of both, back to front");
assert_eq!(list[2]["bounds"], json!([10.0, 10.0, 70.0, 20.0]), "the crop is the art's bounds");
// Collecting the same art again keeps its assets (and isn't an undo step).
let steps = undo_len(&s);
let again = run(&mut s, "assets.add", json!({"ids": [b, a]}));
assert_eq!((again["added"].clone(), again["assets"].clone()), (json!(0), json!([list[0]["id"], list[1]["id"]])));
assert_eq!(undo_len(&s), steps);
// A layer id collects the art under it (Layers panel Collect for Export / MCP).
let layer = s.doc().unwrap().doc.layers[0].id.0;
let from_layer = run(&mut s, "assets.add", json!({"ids": [layer]}));
assert_eq!(from_layer["added"], 0, "those objects are assets already");
assert_eq!(from_layer["assets"], json!([list[0]["id"], list[1]["id"]]));
// An empty layer has nothing to collect.
let empty = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
assert!(s.execute("assets.add", &json!({"ids": [empty]})).is_err());
run(&mut s, "select.set", json!({"ids": []}));
assert!(s.execute("assets.add", &json!({})).is_err());
assert!(s.execute("assets.add", &json!({"ids": [a, b], "name": "Two"})).is_err(), "a name names one asset");
}
#[test]
fn a_layer_id_collects_the_art_under_it_including_sublayers() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let sub = run(&mut s, "layer.newSublayer", json!({}))["id"].as_u64().unwrap();
run(&mut s, "layer.setCurrent", json!({"id": sub}));
let b = rect(&mut s, 50.0, 10.0, 30.0, 20.0);
let layer = s.doc().unwrap().doc.layers[0].id.0;
let r = run(&mut s, "assets.add", json!({"ids": [layer]}));
assert_eq!(r["added"], 2);
let list = assets(&mut s);
assert_eq!(list.len(), 2);
assert_eq!((list[0]["nodes"].clone(), list[1]["nodes"].clone()), (json!([a]), json!([b])));
let one = run(&mut s, "assets.add", json!({"ids": [layer], "multiple": false}));
assert_eq!(one["added"], 1);
assert_eq!(assets(&mut s).last().unwrap()["nodes"], json!([a, b]));
}
#[test]
fn deleting_the_object_prunes_it_and_undo_brings_it_back() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "assets.add", json!({"ids": [a]}));
run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false}));
run(&mut s, "select.set", json!({"ids": [a]}));
run(&mut s, "edit.clear", json!({}));
let list = assets(&mut s);
assert_eq!(list.len(), 1, "the asset of the deleted object goes");
assert_eq!(list[0]["nodes"], json!([b]), "the other keeps the art that is left");
run(&mut s, "edit.undo", json!({}));
let list = assets(&mut s);
assert_eq!((list.len(), list[1]["nodes"].clone()), (2, json!([a, b])));
}
#[test]
fn moving_the_art_moves_the_crop() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a]}));
let before = files(&run(&mut s, "assets.export", json!({})));
run(&mut s, "select.set", json!({"ids": [a]}));
run(&mut s, "object.move", json!({"dx": 40, "dy": 25}));
assert_eq!(assets(&mut s)[0]["bounds"], json!([50.0, 35.0, 20.0, 20.0]));
let after = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(before, after, "the same art, cropped the same way");
assert_eq!(after[0].0, "Asset-1.png");
}
#[test]
fn an_asset_exports_its_art_alone() {
let mut s = session();
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let red = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
// Blue art over it isn't part of the asset.
s.paint.fill = Paint::solid(Color::rgb(0.0, 0.0, 1.0));
rect(&mut s, 0.0, 0.0, 25.0, 25.0);
run(&mut s, "assets.add", json!({"ids": [red]}));
let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "2x"}]})));
assert_eq!(out[0].0, "Asset-1@2x.png");
let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (40, 40));
assert!(img.pixels().all(|p| p.0 == [255, 0, 0, 255]), "only the red square");
// Vector formats crop to it too.
let svg = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "svg"}]})));
let text = String::from_utf8(svg[0].1.clone()).unwrap();
assert_eq!(svg[0].0, "Asset-1.svg");
assert!(text.contains("viewBox=\"10 10 20 20\"") || text.contains("viewBox=\"0 0 20 20\""), "{text}");
assert!(!text.to_lowercase().contains("#0000ff"), "no blue: {text}");
}
/// The crop of an asset takes in its effects: the drop shadow is inside the exported image, and
/// assets.list gives that crop.
#[test]
fn an_assets_crop_takes_in_its_drop_shadow() {
let mut s = session();
let a = rect(&mut s, 50.0, 50.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a]}));
assert_eq!(assets(&mut s)[0]["bounds"], json!([50.0, 50.0, 20.0, 20.0]), "the plain square");
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [a], "params": {"x": 10, "y": 0, "blur": 0}}));
// The shadow's reach (10 pt) on every side.
assert_eq!(assets(&mut s)[0]["bounds"], json!([40.0, 40.0, 40.0, 40.0]));
let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}]})));
let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (40, 40));
// The shadow's right half, x 70 to 80.
assert!(img.get_pixel(35, 20)[3] > 0, "the shadow is in the image");
}
#[test]
fn names_follow_the_object_or_count_up_and_can_be_changed() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 20.0, 20.0), rect(&mut s, 90.0, 10.0, 20.0, 20.0));
run(&mut s, "layer.setProps", json!({"id": b, "name": "Icon"}));
run(&mut s, "assets.add", json!({"ids": [a, b]}));
assert_eq!(names(&mut s), ["Asset 1", "Icon"]);
let first = assets(&mut s)[0]["id"].as_u64().unwrap();
run(&mut s, "assets.rename", json!({"asset": first, "name": " Asset 7 "}));
run(&mut s, "assets.add", json!({"ids": [c]}));
assert_eq!(names(&mut s), ["Asset 7", "Icon", "Asset 8"], "trimmed; numbering goes on from the highest");
run(&mut s, "assets.add", json!({"ids": [a, c], "multiple": false, "name": "Pair"}));
assert_eq!(names(&mut s)[3], "Pair");
assert!(s.execute("assets.rename", &json!({"asset": first, "name": " "})).is_err());
assert!(s.execute("assets.rename", &json!({"asset": 999_999, "name": "X"})).is_err());
// Assets that share a name (in any case) get -2, -3… instead of overwriting.
let ids: Vec<u64> = assets(&mut s).iter().map(|a| a["id"].as_u64().unwrap()).collect();
run(&mut s, "assets.rename", json!({"asset": ids[0], "name": "icon"}));
let out = files(&run(&mut s, "assets.export", json!({"assets": [ids[0], ids[1]]})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["icon.png", "Icon-2.png"]);
}
#[test]
fn add_remove_and_rename_are_undo_steps() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let steps = undo_len(&s);
let id = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap();
run(&mut s, "assets.rename", json!({"asset": id, "name": "Logo"}));
run(&mut s, "assets.remove", json!({"assets": [id]}));
assert_eq!(undo_len(&s), steps + 3);
assert!(assets(&mut s).is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&mut s), ["Logo"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&mut s), ["Asset 1"]);
run(&mut s, "edit.undo", json!({}));
assert!(assets(&mut s).is_empty());
run(&mut s, "edit.redo", json!({}));
assert_eq!(names(&mut s), ["Asset 1"]);
assert!(s.execute("assets.remove", &json!({"assets": [12345]})).is_err());
assert!(s.execute("assets.remove", &json!({})).is_err());
}
#[test]
fn assets_round_trip_through_the_native_format() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false, "name": "Both"}));
let saved = s.doc().unwrap().doc.assets.clone();
let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string();
run(&mut s, "document.open", json!({"name": "assets.vectorcraft", "dataBase64": b64}));
assert_eq!(s.doc().unwrap().doc.assets, saved);
// A new asset never takes an id already used.
let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0);
let id = run(&mut s, "assets.add", json!({"ids": [c]}))["assets"][0].as_u64().unwrap();
assert!(saved.iter().all(|x| x.id != id));
// Documents without assets don't write the key, and files without it load.
let mut d = vectorcraft_doc::Document::new(10.0, 10.0);
let text = serde_json::to_string(&d).unwrap();
assert!(!text.contains("\"assets\""));
d = serde_json::from_str(&text).unwrap();
assert!(d.assets.is_empty());
}
#[test]
fn the_shared_settings_drive_the_export_and_arent_an_undo_step() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a]}));
let steps = undo_len(&s);
let r = run(&mut s, "assets.settings.set", json!({"formats": [{"format": "png", "scale": "1x"}, {"format": "svg"}], "prefix": "ic_"}));
assert_eq!(r["settings"]["prefix"], "ic_");
assert_eq!(undo_len(&s), steps);
assert!(s.doc().unwrap().is_dirty());
// Export for Screens reads them too (they are its remembered settings).
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][1]["format"], "svg");
let out = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["ic_Asset-1.png", "ic_Asset-1.svg"]);
// A preset replaces the rows (and turns on sub-folders); rows replace a preset.
run(&mut s, "assets.settings.set", json!({"preset": "mobile"}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("formats").is_none() && settings["preset"] == "mobile");
let out = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["1x/ic_Asset-1.png", "2x/ic_Asset-1@2x.png", "3x/ic_Asset-1@3x.png"]);
run(&mut s, "assets.settings.set", json!({"formats": [{"format": "jpg", "quality": 80}], "preset": ""}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("preset").is_none() && settings["formats"][0]["format"] == "jpg");
// Params given to one export win without changing the settings.
let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "webp"}], "prefix": null})));
assert_eq!(out[0].0, "Asset-1.webp");
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg");
// Bad settings are refused and change nothing.
for bad in [json!({"formats": [{"format": "dxf"}]}), json!({"preset": "tiny"}), json!({"folder": "/tmp"}), json!([])] {
assert!(s.execute("assets.settings.set", &bad).is_err(), "{bad}");
}
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg");
}
#[test]
fn export_for_screens_writes_chosen_assets_and_keeps_its_rules() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
let ids = run(&mut s, "assets.add", json!({"ids": [a, b]}))["assets"].clone();
let r = run(&mut s, "document.exportForScreens", json!({"assets": [ids[1]], "formats": [{"format": "png", "scale": "60w"}]}));
let out = files(&r);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, "Asset-2@60w.png");
assert_eq!(image::load_from_memory(&out[0].1).unwrap().to_rgba8().dimensions(), (60, 40), "the width of the asset, not the artboard");
// The choice of art isn't remembered; the formats are.
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("assets").is_none() && settings["formats"][0]["scale"] == "60w");
// A zip of every asset.
let zip = run(&mut s, "document.exportForScreens", json!({"assets": ids, "zip": true}));
assert_eq!(zip["files"], json!(["Asset-1.png", "Asset-2.png"]));
for bad in [json!({"assets": [ids[0]], "fullDocument": true}), json!({"assets": [424242]}), json!({"assets": []}), json!({"assets": "all"})] {
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
}
// No assets: nothing for assets.export to do.
run(&mut s, "assets.remove", json!({"assets": ids}));
assert!(s.execute("assets.export", &json!({})).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn assets_export_into_a_folder() {
let dir = std::env::temp_dir().join(format!("vc-assets-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().replace('\\', "/");
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a], "name": "Logo"}));
let r =
run(&mut s, "assets.export", json!({"folder": folder, "subfolders": true, "formats": [{"format": "png", "scale": "2x"}, {"format": "pdf"}]}));
assert_eq!(r["files"], json!([format!("{folder}/2x/Logo@2x.png"), format!("{folder}/PDF/Logo.pdf")]));
assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
/// #983: Asset Export Art Optimized PNG supersamples, so two abutting fills leave no white seam
/// on the shared edge (same idea as `supersampled_art_leaves_no_seam_between_shapes_that_meet`).
#[test]
fn assets_export_leaves_no_seam_between_abutting_shapes() {
let mut s = session();
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
// Share the edge at x = 30; the crop is (10, 10)–(50, 30).
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 30.0, 10.0, 20.0, 20.0));
run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false}));
let out = files(&run(
&mut s,
"assets.export",
json!({
"formats": [{"format": "png", "scale": "1x", "background": "white"}],
"antiAlias": "art",
}),
));
assert_eq!(out[0].0, "Asset-1.png");
let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (40, 20));
// Column 20 is the shared edge in crop space; green must stay red (no white bleed).
let palest_g = (2..18).map(|y| img.get_pixel(20, y).0[1].max(img.get_pixel(19, y).0[1])).max().unwrap();
assert_eq!(palest_g, 0, "supersampled Asset Export: no seam on the shared edge (G={palest_g})");
}
#[test]
fn collecting_explicit_assets_rejects_invalid_ids_without_adding_anything() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0);
let original_selection = s.doc().unwrap().selection.objects.clone();
let before = undo_len(&s);
for ids in [json!([a, "not an object"]), json!([a, 3.5]), json!([a, u64::MAX]), json!({"id": a})] {
assert!(s.execute("assets.add", &json!({"ids": ids})).is_err(), "invalid ids: {ids}");
assert!(assets(&mut s).is_empty(), "invalid ids must not collect only the valid subset");
assert_eq!(undo_len(&s), before);
assert_eq!(s.doc().unwrap().selection.objects, original_selection);
}
// Valid ids still collect art in document paint order.
assert_eq!(run(&mut s, "assets.add", json!({"ids": [b, a]}))["added"], 2);
assert_eq!(assets(&mut s).len(), 2);
}