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

1096 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.
//! Swatch libraries: listing, reading, adding to the document (deduped, one undo step, applied)
//! and Default Swatches.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn list_and_get_every_builtin_library() {
let mut s = Session::new();
let libs = run(&mut s, "swatch.library.list", json!({}));
let libs = libs["libraries"].as_array().unwrap();
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
for id in [
"web-safe-216",
"grays-neutrals",
"earth-tones",
"skin-tone-ramps",
"pastels",
"brights",
"metallic-gradients",
"perceptual-scales",
"harmony-sets",
] {
assert!(ids.contains(&id), "{id} missing from {ids:?}");
}
let web = libs.iter().find(|l| l["id"] == "web-safe-216").unwrap();
assert_eq!((web["name"].as_str(), web["count"].as_u64()), (Some("Web Safe 216"), Some(216)));
// By id or by name in any case; groups list their swatches.
let g = run(&mut s, "swatch.library.get", json!({"library": "earth tones"}));
assert_eq!(g["id"], "earth-tones");
let clay = g["groups"].as_array().unwrap().iter().find(|x| x["name"] == "Clay").unwrap();
assert_eq!(clay["swatches"].as_array().unwrap().len(), 6);
assert!(g["swatches"].as_array().unwrap().iter().all(|w| w["kind"] == "color" && w["group"].is_string()));
let m = run(&mut s, "swatch.library.get", json!({"library": "metallic-gradients"}));
assert!(m["swatches"].as_array().unwrap().iter().all(|w| w["kind"] == "gradient"));
assert!(s.execute("swatch.library.get", &json!({"library": "nope"})).is_err());
}
#[test]
fn add_dedupes_as_one_undo_step() {
let mut s = session();
let before = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", "Ochre 2", "Clay 3"]}));
// The Clay group comes whole (its third swatch once); Ochre 2 comes ungrouped.
let added: Vec<&str> = r["added"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(added.len(), 7, "{added:?}");
assert_eq!(undo_len(&s), before + 1, "one undo step");
let d = doc(&s);
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Clay").map(|g| g.swatches.len()), Some(6));
assert!(d.swatches.iter().any(|w| w.name == "Ochre 2"));
// Adding again finds them all and changes nothing (no undo step).
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", "Ochre 2"]}));
assert_eq!((r["added"].as_array().unwrap().len(), r["existing"].as_array().unwrap().len()), (0, 7));
assert_eq!(undo_len(&s), before + 1);
// A name taken by a different colour gets a number.
run(&mut s, "swatch.new", json!({"name": "Ochre 3", "color": "#123456"}));
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Ochre 3"]}));
assert_eq!(r["added"], json!(["Ochre 3 2"]));
// Undo removes the whole first add.
for _ in 0..3 {
run(&mut s, "edit.undo", json!({}));
}
assert!(doc(&s).swatch_groups.iter().all(|g| g.name != "Clay") && doc(&s).swatch("Ochre 2").is_none());
assert!(s.execute("swatch.library.add", &json!({"library": "earth-tones", "names": ["Nope"]})).is_err());
}
#[test]
fn add_with_apply_paints_the_selection_in_the_same_undo_step() {
let mut s = session();
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
let before = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "web-safe-216", "names": ["#FF6600"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["#FF6600"]), json!("#FF6600")));
let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill.color().map(|c| c.to_hex()), Some("#ff6600".into()));
assert_eq!(undo_len(&s), before + 1, "added and applied as one step");
run(&mut s, "edit.undo", json!({}));
assert!(doc(&s).swatch("#FF6600").is_none());
// A gradient swatch applied to the stroke; an existing swatch is applied without an add.
run(&mut s, "swatch.library.add", json!({"library": "metallic-gradients", "names": ["Gold"], "apply": "stroke"}));
let n = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap();
assert!(matches!(n.appearance.stroke_paint(), Paint::Gradient(_)));
let r = run(&mut s, "swatch.library.add", json!({"library": "metallic-gradients", "names": ["Gold"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!([]), json!("Gold")));
}
#[test]
fn reset_defaults_restores_missing_swatches_or_replaces_them() {
let mut s = session();
let defaults: Vec<String> = doc(&s).swatches_iter().map(|w| w.name.clone()).collect();
run(&mut s, "swatch.delete", json!({"names": ["Red", "Grays"]}));
run(&mut s, "swatch.new", json!({"name": "Mine", "color": "#abcdef"}));
let r = run(&mut s, "swatch.resetDefaults", json!({}));
assert!(r["added"].as_array().unwrap().iter().any(|n| n == "Red"));
let d = doc(&s);
assert!(d.swatch("Mine").is_some(), "the user's swatches stay");
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Grays").map(|g| g.swatches.len()), Some(9));
assert!(defaults.iter().all(|n| d.swatch(n).is_some()));
// Replace: exactly the defaults; art linked to a removed global swatch keeps its colour.
run(&mut s, "swatch.edit", json!({"name": "Mine", "global": true}));
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [id]}));
run(&mut s, "paint.setFill", json!({"swatch": "Mine"}));
assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()), tint: 1.0 });
run(&mut s, "swatch.resetDefaults", json!({"replace": true}));
let d = doc(&s);
assert_eq!(d.swatches_iter().map(|w| w.name.clone()).collect::<Vec<_>>(), defaults);
let fill = d.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill, Paint::solid(Color::from_hex("#abcdef").unwrap()));
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too");
}
/// A fresh scratch folder for one test.
fn temp_dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-swatchlib-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn save_and_load_round_trip_keeps_cmyk_spot_and_groups() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true}));
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]}));
let r = run(&mut s, "swatch.library.save", json!({"names": ["Ink", "Brand", "Sunset"], "name": "Brand Kit"}));
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("vcswatches"), Some(4)));
let data = r["data"].as_str().unwrap().to_string();
let r = run(&mut s, "swatch.library.load", json!({"data": data, "name": "kit.vcswatches"}));
assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Brand Kit"), Some(4)));
let id = r["library"].as_str().unwrap().to_string();
let (_, lib) = cmd::swatchlib::library(&s, &id).unwrap();
let ink = lib.swatch("Ink").unwrap();
assert!(ink.spot && ink.global);
assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
assert_eq!(lib.groups[0].name, "Brand");
assert_eq!(lib.groups[0].swatches.len(), 2);
assert!(matches!(lib.swatch("Sunset").unwrap().paint, Paint::Gradient(_)));
// Listed as loaded; adding from it works like any library.
let list = run(&mut s, "swatch.library.list", json!({}));
assert!(list["libraries"].as_array().unwrap().iter().any(|l| l["id"] == id.as_str() && l["category"] == "loaded"));
assert!(s.execute("swatch.library.save", &json!({"names": ["Nope"]})).is_err());
assert!(s.execute("swatch.library.save", &json!({"user": true})).is_err(), "no user folder in a headless session");
}
/// The swatch library commands, and the graphic style library save and load that share their file
/// handling, refuse a parameter of the wrong form, such as `user: "true"` or `names: "Grays"`, with
/// an error that names it; the document keeps its swatches and no undo step is recorded. A null
/// counts as a value left out.
#[test]
fn library_commands_refuse_params_of_the_wrong_form() {
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
let swatches = |s: &Session| doc(s).swatches_iter().map(|w| w.name.clone()).collect::<Vec<_>>();
let (names, before) = (swatches(&s), undo_len(&s));
let data = run(&mut s, "swatch.library.save", json!({"names": ["Grays"]}))["data"].clone();
for (cmd, p, msg) in [
("swatch.library.get", json!({"library": 5}), "`library` must be a string"),
("swatch.library.add", json!({"library": "earth-tones", "names": "Clay"}), "`names` must be a list of names"),
("swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", 3]}), "`names` must be a list of names"),
("swatch.library.add", json!({"library": "earth-tones", "names": ["Clay"], "apply": 5}), "`apply` must be a string"),
("swatch.library.add", json!({"library": "earth-tones", "apply": "fill", "focus": "true"}), "`focus` must be true or false"),
("swatch.resetDefaults", json!({"replace": "true"}), "`replace` must be true or false"),
("swatch.library.save", json!({"user": "true"}), "`user` must be true or false"),
("swatch.library.save", json!({"path": 5}), "`path` must be a string"),
("swatch.library.save", json!({"format": 5}), "`format` must be a string"),
("swatch.library.save", json!({"name": 5}), "`name` must be a string"),
("swatch.library.save", json!({"names": "Grays"}), "`names` must be a list of names"),
("swatch.library.load", json!({"path": 5, "data": data}), "`path` must be a string"),
("swatch.library.load", json!({"data": 5}), "`data` must be a string"),
("swatch.library.load", json!({"dataBase64": 5}), "`dataBase64` must be a string"),
("swatch.library.load", json!({"data": data, "name": 5}), "`name` must be a string"),
("graphicStyle.saveLibrary", json!({"user": "true"}), "`user` must be true or false"),
("graphicStyle.saveLibrary", json!({"names": "Halo"}), "`names` must be a list of names"),
("graphicStyle.saveLibrary", json!({"name": 5}), "`name` must be a string"),
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "names": "Halo"}), "`names` must be a list of names"),
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "name": 5}), "`name` must be a string"),
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": "true"}), "`apply` must be true or false"),
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": true, "add": "true"}), "`add` must be true or false"),
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": true, "ids": "x"}), "ids must be an array"),
("graphicStyle.loadLibrary", json!({"path": 5, "data": "{}"}), "`path` must be a string"),
] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains(msg), "{cmd} {p}: {e}");
}
assert_eq!((swatches(&s), undo_len(&s)), (names.clone(), before), "nothing changed");
let r = run(&mut s, "swatch.library.save", json!({"path": null, "format": null, "name": null, "names": null, "user": null}));
assert_eq!(r["format"], "vcswatches");
let r = run(&mut s, "swatch.library.load", json!({"path": null, "data": data, "dataBase64": null, "name": null}));
assert_eq!(r["count"], 9);
run(&mut s, "swatch.resetDefaults", json!({"replace": null}));
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": null, "apply": null, "focus": null}));
assert!(r["added"].as_array().is_some_and(|a| !a.is_empty()) && r.get("applied").is_none(), "{r}");
run(&mut s, "graphicStyle.saveLibrary", json!({"name": null, "names": null}));
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "name": null, "names": null}));
assert!(r["added"].as_array().is_some_and(|a| !a.is_empty()), "{r}");
}
#[test]
fn library_loads_validate_name_before_reading_a_file_or_decoding_data() {
let mut s = session();
let missing = temp_dir("invalid-load-name").join("missing.vcswatches").to_string_lossy().to_string();
for cmd in ["swatch.library.load", "graphicStyle.loadLibrary", "symbol.loadLibrary", "brush.loadLibrary"] {
for name in [json!(5), json!([])] {
let p = json!({"path": missing, "name": name});
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("`name` must be a string"), "{cmd} {p}: {e}");
}
let p = json!({"dataBase64": "not base64!", "name": 5});
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("`name` must be a string"), "{cmd} {p}: {e}");
}
}
#[test]
fn libraries_saved_in_the_user_folder_are_user_defined() {
let dir = temp_dir("user");
let mut s = session();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "My: Greys", "format": "gpl", "names": ["Grays"]}));
assert_eq!(r["library"], "user/My- Greys.gpl");
let path = r["path"].as_str().unwrap().to_string();
assert!(std::fs::read_to_string(&path).unwrap().starts_with("GIMP Palette\nName: My: Greys\n"));
let list = run(&mut s, "swatch.library.list", json!({}));
let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect();
assert_eq!(user.len(), 1);
assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Greys"), Some(9)));
// Opening a file of the user folder gives its User Defined library.
let r = run(&mut s, "swatch.library.load", json!({ "path": path }));
assert_eq!(r["library"], "user/My- Greys.gpl");
// CSS by extension.
let css = dir.join("out.css").to_string_lossy().to_string();
run(&mut s, "swatch.library.save", json!({ "path": css }));
assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;"));
// A name with nothing a file name can keep is saved as Library.vcswatches, and User Defined lists it.
let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "..."}));
assert_eq!(r["library"], "user/Library.vcswatches");
assert_eq!(cmd::swatchlib::library(&s, "user/Library.vcswatches").map(|(info, _)| info.name), Some("...".into()));
let _ = std::fs::remove_dir_all(dir);
}
/// A library kind whose reader panics on a file that holds `trap`, as a parser bug would.
#[derive(Default)]
struct Trap(String);
impl cmd::swatchlib::LibraryFile for Trap {
const EXTS: &'static [&'static str] = &["trap"];
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
if bytes == b"trap" {
panic!("a parser bug");
}
Ok(Self(stem.to_string()))
}
fn name(&self) -> &str {
&self.0
}
}
/// When reading one file of the user library folder panics, `set_user_dir` (as the desktop app
/// calls it at launch) skips that file and lists the other one.
#[test]
fn a_user_library_file_whose_reading_panics_is_skipped() {
let dir = temp_dir("user-panic");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Calm.trap"), "calm").unwrap();
std::fs::write(dir.join("Loud.trap"), "trap").unwrap();
let mut libraries = cmd::swatchlib::Libraries::<Trap>::default();
libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let names: Vec<&str> = libraries.infos().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Calm"]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn another_documents_swatches_load_as_a_library() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"}));
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Poster.vectorcraft"}));
assert_eq!(r["name"], "Poster");
let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap();
assert!(lib.swatch("Signal").is_some() && lib.swatch("[None]").is_none());
assert!(s.execute("swatch.library.load", &json!({"data": "hello", "name": "x.txt"})).is_err());
}
#[test]
fn swatch_exchange_files_load_add_and_list_as_user_libraries() {
let bytes = vectorcraft_testkit::ase::sample();
let mut s = session();
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Brand.ase"}));
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some("loaded/Brand.ase"), Some("Brand"), Some(4)));
let g = run(&mut s, "swatch.library.get", json!({"library": r["library"]}));
let swatches = g["swatches"].as_array().unwrap();
let kinds: Vec<(&str, bool, bool)> =
swatches.iter().map(|w| (w["name"].as_str().unwrap(), w["global"].as_bool().unwrap(), w["spot"].as_bool().unwrap())).collect();
assert_eq!(kinds, [("Sky", true, false), ("Ink", true, true), ("Mist", false, false), ("Clay", false, false)]);
assert_eq!(swatches[3]["color"], json!({"model": "lab", "l": 50.0, "a": 20.0, "b": -30.0}));
// Added to the document, the spot color stays a spot color and the group stays a color group.
run(&mut s, "swatch.library.add", json!({"library": r["library"]}));
let d = doc(&s);
assert!(d.swatch("Ink").is_some_and(|w| w.spot && w.global));
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Neutrals").map(|g| g.swatches.len()), Some(2));
// A damaged file is an error from the swatch exchange reader, not from the document loader.
let cut = vectorcraft_format::base64_encode(&bytes[..bytes.len() - 1]);
let e = s.execute("swatch.library.load", &json!({"dataBase64": cut, "name": "Cut.ase"})).unwrap_err().to_string();
assert!(e.contains("cut short"), "{e}");
// `.ase` files in the user library folder are User Defined libraries.
let dir = temp_dir("ase");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Brand.ase"), &bytes).unwrap();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let list = run(&mut s, "swatch.library.list", json!({}));
let user: Vec<(&str, u64)> = list["libraries"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["category"] == "user")
.map(|l| (l["id"].as_str().unwrap(), l["count"].as_u64().unwrap()))
.collect();
assert_eq!(user, [("user/Brand.ase", 4)]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn swatch_exchange_files_save_with_models_kinds_and_groups() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true}));
run(&mut s, "swatch.new", json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}}));
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]}));
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "names": ["Ink", "Clay", "Brand", "Grays", "Sunset"], "name": "Brand Kit"}));
assert_eq!(r["format"], "ase");
assert!(r.get("data").is_none(), "a binary file comes back as dataBase64");
let b64 = r["dataBase64"].as_str().unwrap().to_string();
assert!(vectorcraft_format::base64_decode(&b64).unwrap().starts_with(b"ASEF"));
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Brand Kit.ase"}));
let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap();
assert_eq!(lib.name, "Brand Kit", "named after the file");
let ink = lib.swatch("Ink").unwrap();
assert!(ink.spot && ink.global);
assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
assert_eq!(lib.swatch("Clay").unwrap().paint.color(), Some(Color::lab(50.0, 20.0, -30.0)));
assert!(lib.swatch("Sunset").is_none(), "a gradient is left out");
let groups: Vec<(&str, usize)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.len())).collect();
assert_eq!(groups, [("Grays", 9), ("Brand", 2)], "in document order");
// Gray colors come back within float rounding.
let grays = &doc(&s).swatch_groups.iter().find(|g| g.name == "Grays").unwrap().swatches;
for (w, want) in lib.groups[0].swatches.iter().zip(grays) {
let (Some(Color::Gray { k }), Some(Color::Gray { k: want_k })) = (w.paint.color(), want.paint.color()) else {
panic!("{} isn't gray", w.name)
};
assert!(w.name == want.name && (k - want_k).abs() < 1e-4, "{}: {k} for {want_k}", w.name);
}
// Into the user library folder, and to a path ending in .ase.
let dir = temp_dir("ase-save");
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "user": true, "name": "Brand Kit"}));
assert_eq!(r["library"], "user/Brand Kit.ase");
assert!(std::fs::read(r["path"].as_str().unwrap()).unwrap().starts_with(b"ASEF"));
assert!(cmd::swatchlib::library(&s, "user/Brand Kit.ase").is_some(), "the user folder lists it");
let path = dir.join("out.ase").to_string_lossy().to_string();
assert_eq!(run(&mut s, "swatch.library.save", json!({ "path": path }))["format"], "ase");
assert!(std::fs::read(&path).unwrap().starts_with(b"ASEF"));
let e = s.execute("swatch.library.save", &json!({"format": "aco"})).unwrap_err().to_string();
assert!(e.contains("vcswatches, gpl, ase or css"), "{e}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn save_counts_the_swatches_the_file_holds() {
let mut s = session();
// A new document has 46 solid colors and 4 linear or radial gradients besides None. Mesh is a freeform gradient.
run(&mut s, "swatch.new", json!({"name": "Mesh", "gradient": {"kind": "freeform"}}));
let mut counts = vec![];
for format in ["vcswatches", "gpl", "ase", "css"] {
let r = run(&mut s, "swatch.library.save", json!({ "format": format }));
// The libraries load back with that many swatches, and the CSS file has one property for each.
let held = if format == "css" {
r["data"].as_str().unwrap().lines().filter(|l| l.starts_with(" --")).count() as u64
} else {
let load = json!({"data": r["data"], "dataBase64": r["dataBase64"], "name": format!("Saved.{format}")});
run(&mut s, "swatch.library.load", load)["count"].as_u64().unwrap()
};
counts.push((format, r["count"].as_u64().unwrap(), held));
}
assert_eq!(counts, [("vcswatches", 51, 51), ("gpl", 46, 46), ("ase", 46, 46), ("css", 50, 50)]);
}
#[test]
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
use vectorcraft_testkit::ase::{Block, ase, sample};
let dir = temp_dir("copy");
let (user, elsewhere) = (dir.join("user"), dir.join("elsewhere"));
std::fs::create_dir_all(&user).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let source = elsewhere.join("Brand.ase");
std::fs::write(&source, sample()).unwrap();
let source = source.to_string_lossy().to_string();
// Another library whose file name differs only in case.
let other = ase(&[Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)]);
std::fs::write(user.join("brand.ase"), &other).unwrap();
let mut s = session();
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
assert_eq!(loaded, format!("loaded/{source}"));
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
// A swatch exchange library is named after its file.
assert_eq!(
(r["library"].as_str(), r["name"].as_str(), r["count"].as_u64(), r["copied"].as_bool()),
(Some("user/Brand 2.ase"), Some("Brand 2"), Some(4), Some(true))
);
assert_eq!(r["path"], user.join("Brand 2.ase").to_string_lossy().to_string());
assert_eq!(std::fs::read(user.join("Brand 2.ase")).unwrap(), sample(), "the file as it is");
assert_eq!(std::fs::read(user.join("brand.ase")).unwrap(), other, "never replaced");
let listed = |s: &mut Session| -> Vec<String> {
let list = run(s, "swatch.library.list", json!({}));
let libs = list["libraries"].as_array().unwrap().iter().filter(|l| matches!(l["category"].as_str(), Some("user" | "loaded")));
libs.map(|l| l["id"].as_str().unwrap().to_string()).collect()
};
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"], "listed as User Defined only");
// The loaded id finds the copy, so choices made with it (Limit to Library) keep their colors.
let found = cmd::swatchlib::library(&s, loaded.as_str().unwrap()).map(|(info, _)| info.id);
assert_eq!(found.as_deref(), Some("user/Brand 2.ase"));
assert!(cmd::swatchlib::limit_palette(&s, loaded.as_str().unwrap()).is_some());
// The same file again opens the copy, which is listed once; copying it writes nothing.
let again = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
assert_eq!(again, "user/Brand 2.ase");
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"]);
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": again }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Brand 2.ase"), Some(false)));
assert_eq!(std::fs::read_dir(&user).unwrap().count(), 2);
// A document's swatches and a library loaded from data are written as .vcswatches.
run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"}));
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
let poster = elsewhere.join("Poster.vectorcraft");
std::fs::write(&poster, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(true)));
assert!(cmd::swatchlib::library(&s, "user/Poster.vcswatches").unwrap().1.swatch("Signal").is_some());
// Copied again, the document's swatches are written as the same bytes: the copy there is used.
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(false)));
let kit = vectorcraft_format::base64_encode(&sample());
let loaded = run(&mut s, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
assert_eq!(run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }))["library"], "user/Kit.vcswatches");
// A library without a name is copied as Library.
let loaded = run(&mut s, "swatch.library.load", json!({"data": "GIMP Palette\nName:\n0 0 0 Black\n"}))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Library.vcswatches"), Some("Library"), Some(true)));
// When the source no longer reads as a library, the copy fails and no new file is left in the folder.
let junk = elsewhere.join("Junk.ase");
std::fs::write(&junk, ase(&[Block::Color("Junk", b"RGB ", &[0.0, 1.0, 0.0], 2)])).unwrap();
let loaded = run(&mut s, "swatch.library.load", json!({ "path": junk.to_string_lossy() }))["library"].clone();
std::fs::write(&junk, b"not a library").unwrap();
let files = std::fs::read_dir(&user).unwrap().count();
let e = s.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
assert!(e.contains("the copy `user/Junk.ase` can't be read as a library"), "{e}");
assert_eq!(std::fs::read_dir(&user).unwrap().count(), files);
// A User Defined library comes back as it is; a built-in one is an error.
let r = run(&mut s, "swatch.library.copyToUser", json!({"library": "user/brand.ase"}));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/brand.ase"), Some(false)));
assert!(s.execute("swatch.library.copyToUser", &json!({"library": "pastels"})).unwrap_err().to_string().contains("built in"));
assert!(s.execute("swatch.library.copyToUser", &json!({})).is_err());
// Without a user library folder (the web, headless sessions) there is nowhere to copy to, and
// the command is off.
let mut headless = session();
let info = find_command("swatch.library.copyToUser").unwrap().info(&headless);
assert!(!info.enabled && info.disabled_reason.as_deref() == Some("no user library folder here"), "{info:?}");
assert!(find_command("swatch.library.copyToUser").unwrap().info(&s).enabled);
let loaded = run(&mut headless, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
let e = headless.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
assert!(e.contains("no user library folder"), "{e}");
let _ = std::fs::remove_dir_all(dir);
}
/// A color book opened from a host temporary path (macOS sandbox copies) is stored under its title
/// when copied into the user library folder (#2996).
#[test]
fn copying_a_color_book_from_a_temporary_path_uses_its_title() {
fn acb_str(out: &mut Vec<u8>, s: &str) {
let units: Vec<u16> = s.encode_utf16().collect();
out.extend((units.len() as u32).to_be_bytes());
units.iter().for_each(|u| out.extend(u.to_be_bytes()));
}
let mut bytes = b"8BCB".to_vec();
bytes.extend(1u16.to_be_bytes());
bytes.extend(3000u16.to_be_bytes());
for s in ["$$$/colorbook/Test/title=Test Book^R", "TEST ", " C", "a book"] {
acb_str(&mut bytes, s);
}
bytes.extend(1u16.to_be_bytes());
bytes.extend(7u16.to_be_bytes());
bytes.extend(0u16.to_be_bytes());
bytes.extend(0u16.to_be_bytes());
acb_str(&mut bytes, "185");
bytes.extend(b"TST001");
bytes.extend([228, 0, 43]);
bytes.extend(b"spflspot");
let dir = temp_dir("acb-temp");
let user = dir.join("user");
let elsewhere = dir.join("elsewhere");
std::fs::create_dir_all(&user).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let source = elsewhere.join("tmp1F2C.tmp.acb");
std::fs::write(&source, &bytes).unwrap();
let source = source.to_string_lossy().to_string();
let mut s = session();
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": loaded }))["name"], "Test Book®");
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Test Book®.acb"), Some("Test Book®"), Some(true)));
assert_eq!(r["path"], user.join("Test Book®.acb").to_string_lossy().to_string());
assert_eq!(std::fs::read(user.join("Test Book®.acb")).unwrap(), bytes);
let _ = std::fs::remove_dir_all(dir);
}
/// Color books in the user library folder and opened by path show the file name as the tab title
/// (#3395), not only the title stored inside the book.
#[test]
fn color_book_tab_titles_use_the_file_name() {
fn acb_bytes(title: &str) -> Vec<u8> {
fn acb_str(out: &mut Vec<u8>, s: &str) {
let units: Vec<u16> = s.encode_utf16().collect();
out.extend((units.len() as u32).to_be_bytes());
units.iter().for_each(|u| out.extend(u.to_be_bytes()));
}
let mut bytes = b"8BCB".to_vec();
bytes.extend(1u16.to_be_bytes());
bytes.extend(3000u16.to_be_bytes());
for s in [title, "PANTONE ", " C", ""] {
acb_str(&mut bytes, s);
}
bytes.extend(1u16.to_be_bytes());
bytes.extend(7u16.to_be_bytes());
bytes.extend(0u16.to_be_bytes());
bytes.extend(0u16.to_be_bytes());
acb_str(&mut bytes, "185");
bytes.extend(b"TST001");
bytes.extend([228, 0, 43]);
bytes.extend(b"spflspot");
bytes
}
let dir = temp_dir("acb-name");
let user = dir.join("user");
let elsewhere = dir.join("elsewhere");
std::fs::create_dir_all(&user).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let on_disk = user.join("PANTONE Solid Coated.acb");
std::fs::write(&on_disk, acb_bytes("Internal Book Title")).unwrap();
let named = elsewhere.join("Brand Colors.acb");
std::fs::write(&named, acb_bytes("Another Internal Title")).unwrap();
let mut s = session();
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": "user/PANTONE Solid Coated.acb" }))["name"], "PANTONE Solid Coated");
let loaded = run(&mut s, "swatch.library.load", json!({ "path": named.to_string_lossy() }))["library"].clone();
assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": loaded }))["name"], "Brand Colors");
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Brand Colors.acb"), Some("Brand Colors"), Some(true)));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_library_group_stays_one_group_when_a_swatch_has_its_name() {
use vectorcraft_testkit::ase::{Block, ase};
let mut s = session();
assert!(doc(&s).swatch("Blue").is_some(), "a new document has a swatch named Blue");
let bytes = ase(&[
Block::GroupStart("Blue"),
Block::Color("Deep", b"RGB ", &[0.0, 0.0, 0.4], 2),
Block::Color("Pale", b"RGB ", &[0.6, 0.7, 1.0], 2),
Block::GroupEnd,
Block::GroupStart("Blue 2"),
Block::Color("Frost", b"RGB ", &[0.9, 0.95, 1.0], 2),
Block::GroupEnd,
]);
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Blues.ase"}));
run(&mut s, "swatch.library.add", json!({"library": r["library"]}));
let groups: Vec<(&str, Vec<&str>)> =
doc(&s).swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert!(groups.contains(&("Blue 2", vec!["Deep", "Pale"])), "{groups:?}");
assert!(groups.contains(&("Blue 2 2", vec!["Frost"])), "the library's own Blue 2 stays apart: {groups:?}");
}
#[test]
fn text_libraries_sent_as_bytes_load_when_they_are_not_valid_utf8() {
let mut s = session();
let latin1 = vectorcraft_format::base64_encode(b"GIMP Palette\n237 28 36 Rouge fonc\xe9\n");
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": latin1, "name": "Rouge.gpl"}));
assert_eq!(r["count"], 1);
}
#[test]
fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradients() {
let mut s = session();
let list = run(&mut s, "swatch.library.list", json!({}));
let grads: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "gradients").collect();
assert!(grads.len() >= 5 && grads.iter().any(|l| l["id"] == "sky-gradients"));
run(&mut s, "swatch.library.add", json!({"library": "sky-gradients", "names": ["Dawn"]}));
// Rename keeps the gradient.
let before = doc(&s).swatch("Dawn").unwrap().paint.clone();
run(&mut s, "swatch.edit", json!({"name": "Dawn", "newName": "Early"}));
assert_eq!(doc(&s).swatch("Early").unwrap().paint, before);
// An edited gradient replaces it, unplaced and unlinked; one undo step.
let undo = undo_len(&s);
let g =
json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "swatch": "Early"}});
run(&mut s, "swatch.edit", json!({"name": "Early", "paint": g}));
let Paint::Gradient(gp) = &doc(&s).swatch("Early").unwrap().paint else { panic!("a gradient") };
assert_eq!((gp.gradient.kind, gp.gradient.stops.len(), gp.swatch.clone(), gp.geom), (vectorcraft_color::GradientKind::Radial, 2, None, None));
assert_eq!(undo_len(&s), undo + 1);
// Kinds don't mix; a colour replaces a colour.
assert!(s.execute("swatch.edit", &json!({"name": "Early", "paint": {"color": "#00ff00"}})).is_err());
run(&mut s, "swatch.edit", json!({"name": "Red", "paint": {"color": "#00ff00"}}));
assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into()));
}
#[test]
fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() {
let lib = vectorcraft_color::libraries::builtin_library("organic-fades").unwrap();
for mode in ["rgb", "cmyk"] {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode}));
let list = run(&mut s, "swatch.library.list", json!({}));
let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "organic-fades").unwrap().clone();
assert_eq!(
(row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()),
(Some("Organic Fades"), Some("gradients"), Some(lib.len() as u64))
);
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
let r = run(&mut s, "swatch.library.add", json!({"library": "organic-fades", "names": ["Petal", "Dew"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Petal", "Dew"]), json!("Petal")), "{mode}");
for name in ["Petal", "Dew"] {
// The swatch comes as the library has it: stops (colors as given, opacities, midpoints) and method.
let Paint::Gradient(added) = &doc(&s).swatch(name).unwrap().paint else { panic!("{name} is a gradient") };
let Paint::Gradient(want) = &lib.swatch(name).unwrap().paint else { panic!("{name} is a gradient") };
assert_eq!(added.gradient, want.gradient, "{mode} {name}");
assert_eq!(added.gradient.method, vectorcraft_color::GradientMethod::Perceptual);
assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode} {name}: the stops stay RGB");
assert!(added.gradient.stops.iter().any(|st| st.opacity < 1.0 && (st.midpoint - 0.5).abs() > 0.02), "{mode} {name}");
}
// The fill takes the fade's method, opacities and midpoints.
let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
let (Paint::Gradient(fill), Paint::Gradient(want)) = (fill, &lib.swatch("Petal").unwrap().paint) else { panic!("a gradient fill") };
assert_eq!(fill.gradient.method, want.gradient.method, "{mode}");
let shape = |g: &vectorcraft_color::Gradient| g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect::<Vec<_>>();
assert_eq!(shape(&fill.gradient), shape(&want.gradient), "{mode}");
}
}
#[test]
fn holographic_glows_add_whole_in_rgb_and_cmyk_as_one_undo_step() {
let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap();
let Paint::Gradient(want) = &lib.swatch("Mint Halo").unwrap().paint else { panic!("Mint Halo is a gradient") };
for mode in ["rgb", "cmyk"] {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode}));
let list = run(&mut s, "swatch.library.list", json!({}));
let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "holographic").unwrap().clone();
assert_eq!((row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), (Some("Holographic"), Some("gradients"), Some(14)));
let got = run(&mut s, "swatch.library.get", json!({"library": "holographic"}));
assert!(got.to_string().contains("Mint Halo"), "{mode}: get lists Mint Halo");
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
let node = vectorcraft_doc::NodeId(id.as_u64().unwrap());
let before = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Mint Halo"]), json!("Mint Halo")), "{mode}");
assert_eq!(undo_len(&s), before + 1, "{mode}: added and applied as one step");
// The swatch comes as the library has it: kind, stops (RGB colors, opacities, midpoints) and method.
let Paint::Gradient(added) = &doc(&s).swatch("Mint Halo").unwrap().paint else { panic!("{mode}: a gradient swatch") };
assert_eq!(added.gradient, want.gradient, "{mode}");
assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode}: the stops stay RGB");
assert_eq!(added.gradient.stops.last().map(|st| st.opacity), Some(0.0), "{mode}: clear at the edge");
let Paint::Gradient(fill) = doc(&s).node(node).unwrap().appearance.fill_paint() else { panic!("{mode}: a gradient fill") };
assert_eq!((fill.gradient.kind, fill.gradient.method), (want.gradient.kind, want.gradient.method), "{mode}");
assert_eq!(fill.gradient.stops, want.gradient.stops, "{mode}");
run(&mut s, "edit.undo", json!({}));
assert!(doc(&s).swatch("Mint Halo").is_none(), "{mode}: undo removes the add");
assert!(!matches!(doc(&s).node(node).unwrap().appearance.fill_paint(), Paint::Gradient(_)), "{mode}: and the fill");
// A document swatch already named Mint Halo stays; the glow comes in under a number.
run(&mut s, "swatch.new", json!({"name": "Mint Halo", "color": "#123456"}));
let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"]}));
assert_eq!(r["added"], json!(["Mint Halo 2"]), "{mode}");
assert!(doc(&s).swatch("Mint Halo").unwrap().paint.color().is_some(), "{mode}: the document's swatch is kept");
assert!(matches!(&doc(&s).swatch("Mint Halo 2").unwrap().paint, Paint::Gradient(g) if g.gradient == want.gradient));
}
}
#[test]
fn pattern_libraries_bring_their_patterns_and_add_each_once() {
let mut s = session();
let list = run(&mut s, "swatch.library.list", json!({}));
let libs = list["libraries"].as_array().unwrap();
let categories: Vec<&str> = libs.iter().map(|l| l["category"].as_str().unwrap()).collect();
let first = categories.iter().position(|c| *c == "patterns").expect("a pattern library");
assert!(categories.iter().rposition(|c| *c == "gradients") < Some(first), "{categories:?}");
let geo = libs.iter().find(|l| l["id"] == "geometric-patterns").unwrap();
assert_eq!(geo["name"], "Geometric Patterns");
let got = run(&mut s, "swatch.library.get", json!({"library": "geometric patterns"}));
let swatches = got["swatches"].as_array().unwrap();
assert_eq!(swatches.len() as u64, geo["count"].as_u64().unwrap());
assert!(swatches.iter().all(|w| w["kind"] == "pattern" && w["pattern"] == w["name"]));
// Clicking a pattern adds it with its definition and fills the selection with it, one undo step.
let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap());
let before = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Polka Dots"]), json!("Polka Dots")));
assert_eq!(undo_len(&s), before + 1, "added and applied as one step");
let d = doc(&s);
let def = d.pattern("Polka Dots").expect("the pattern comes with its swatch");
assert!(!def.art.is_empty());
assert!(matches!(d.swatch("Polka Dots").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Polka Dots"));
assert!(matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots"));
// Again: nothing is added; the stroke takes the same pattern.
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
let had = counts(doc(&s));
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "stroke"}));
assert_eq!((r["added"].clone(), r["existing"].clone(), r["applied"].clone()), (json!([]), json!(["Polka Dots"]), json!("Polka Dots")));
assert_eq!(counts(doc(&s)), had);
assert!(matches!(doc(&s).node(id).unwrap().appearance.stroke_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots"));
// A different pattern under the name: the library's comes in numbered, with its swatch.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
s.edit("Pattern", |d, _| {
let dot = vectorcraft_doc::Node::path(
d.alloc_id(),
vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0)),
vectorcraft_doc::Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0),
);
d.patterns.push(vectorcraft_doc::PatternDef::new("Polka Dots", vec![std::sync::Arc::new(dot)]));
d.swatches.push(vectorcraft_color::Swatch {
name: "Polka Dots".into(),
paint: vectorcraft_doc::pattern::pattern_paint("Polka Dots"),
global: false,
spot: false,
});
Ok(())
})
.unwrap();
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "apply": "fill"}));
let added: Vec<&str> = r["added"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!((added.len(), added[0], r["applied"].as_str()), (swatches.len(), "Polka Dots 2", Some("Polka Dots 2")));
let d = doc(&s);
assert!(added.iter().all(|n| d.pattern(n).is_some() && d.swatch(n).is_some()), "{added:?}");
assert!(matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots 2"));
// A swatch of the pattern moved in its space is not the pattern's own swatch: a new one is added
// and applied, and no second pattern.
s.edit("Move", |d, _| {
if let Some(w) = d.swatches_iter_mut().find(|w| w.name == "Polka Dots 2") {
w.paint = Paint::Pattern { pattern: "Polka Dots 2".into(), xf: kurbo::Affine::translate((3.0, 0.0)) };
}
Ok(())
})
.unwrap();
let patterns = doc(&s).patterns.len();
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "fill"}));
let applied = r["applied"].as_str().unwrap().to_string();
assert_ne!(applied, "Polka Dots 2");
let d = doc(&s);
assert_eq!(
(d.patterns.len(), d.swatch(&applied).map(|w| w.paint.clone())),
(patterns, Some(vectorcraft_doc::pattern::pattern_paint("Polka Dots 2")))
);
assert_eq!(d.node(id).unwrap().appearance.fill_paint(), vectorcraft_doc::pattern::pattern_paint("Polka Dots 2"));
// A pattern library has no colours to limit to, and is built in.
assert!(s.execute("swatch.library.copyToUser", &json!({"library": "geometric-patterns"})).is_err());
}
/// The built-in pattern libraries: unique, tiling patterns with art, whose names no two libraries
/// share. Each library's own swatches draw from it; a swatch of the same name elsewhere does not.
#[test]
fn built_in_pattern_libraries_hold_tiling_patterns_with_unique_names() {
use crate::cmd::patternlib::{PATTERN_LIBRARIES, library, library_doc, swatch_source};
let mut names = std::collections::HashSet::new();
for b in PATTERN_LIBRARIES {
let d = library_doc(b.id).unwrap();
assert!(!d.patterns.is_empty(), "{}", b.id);
for p in &d.patterns {
assert!(names.insert(p.name.clone()), "{} is in two libraries", p.name);
assert!(!p.art.is_empty() && p.width() > 0.0 && p.height() > 0.0, "{}", p.name);
assert!(vectorcraft_render::render_pattern_swatch(&d, &p.name, 16).is_some(), "{}", p.name);
}
let (_, lib) = library(b.id).unwrap();
for w in &lib.swatches {
assert!(std::sync::Arc::ptr_eq(&swatch_source(w).unwrap(), &d), "{}", w.name);
assert!(swatch_source(&w.clone()).is_none(), "a copy of {} is another library's swatch", w.name);
}
}
}
/// Listing and reading a pattern library add nothing to the document. A pattern added from it
/// undoes and redoes with its swatch, and both stay after a save and reopen.
#[test]
fn a_pattern_library_adds_only_the_picked_pattern_and_it_undoes_and_saves() {
let mut s = session();
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
let empty = counts(doc(&s));
run(&mut s, "swatch.library.list", json!({}));
run(&mut s, "swatch.library.get", json!({"library": "geometric-patterns"}));
assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch");
run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Checkerboard"]}));
assert_eq!(counts(doc(&s)), (empty.0 + 1, empty.1 + 1), "only the picked pattern and its swatch");
let has = |d: &vectorcraft_doc::Document| d.pattern("Checkerboard").is_some() && d.swatch("Checkerboard").is_some();
assert!(has(doc(&s)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(counts(doc(&s)), empty);
assert!(doc(&s).pattern("Checkerboard").is_none() && doc(&s).swatch("Checkerboard").is_none());
run(&mut s, "edit.redo", json!({}));
assert!(has(doc(&s)));
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
assert_eq!(back.patterns, doc(&s).patterns);
assert!(has(&back));
assert_eq!(back.swatch("Checkerboard").map(|w| &w.paint), doc(&s).swatch("Checkerboard").map(|w| &w.paint));
}
/// Grit Textures is listed after Geometric Patterns. One of its patterns comes in with its swatch
/// once, as one undo step that also takes both away, and both stay after a save and reopen.
#[test]
fn a_grit_texture_adds_once_undoes_and_saves() {
let mut s = session();
let list = run(&mut s, "swatch.library.list", json!({}));
let ids: Vec<&str> =
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]);
let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"}));
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]);
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
let empty = counts(doc(&s));
let undo = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]}));
assert_eq!(r["added"], json!(["Stipple"]));
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1));
let def = doc(&s).pattern("Stipple").expect("the pattern comes with its swatch").clone();
assert!(def.art.len() >= 144, "{} marks", def.art.len());
assert!(matches!(doc(&s).swatch("Stipple").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Stipple"));
let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]}));
assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Stipple"])));
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once");
run(&mut s, "edit.undo", json!({}));
assert_eq!(counts(doc(&s)), empty);
assert!(doc(&s).pattern("Stipple").is_none() && doc(&s).swatch("Stipple").is_none());
run(&mut s, "edit.redo", json!({}));
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
assert_eq!(back.pattern("Stipple"), Some(&def));
assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint));
}
/// Optical Effects is listed after Grit Textures, and reading it adds nothing. Clicking one of
/// its patterns brings it with its swatch and fills the selection with it, once, as one undo step
/// that also takes both away, and both stay after a save and reopen.
#[test]
fn an_optical_effect_adds_once_fills_undoes_and_saves() {
let mut s = session();
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
let empty = counts(doc(&s));
let list = run(&mut s, "swatch.library.list", json!({}));
let ids: Vec<&str> =
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]);
let got = run(&mut s, "swatch.library.get", json!({"library": "Optical Effects"}));
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
assert_eq!(
names,
["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"]
);
assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch");
let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap());
let undo = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Warped Checks"]), json!("Warped Checks")));
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1));
let def = doc(&s).pattern("Warped Checks").expect("the pattern comes with its swatch").clone();
assert_eq!(def.art.len(), 32, "the black cells of an 8 × 8 checkerboard");
assert!(matches!(doc(&s).swatch("Warped Checks").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Warped Checks"));
let filled = |d: &vectorcraft_doc::Document| matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Warped Checks");
assert!(filled(doc(&s)));
let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"]}));
assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Warped Checks"])));
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once");
run(&mut s, "edit.undo", json!({}));
assert_eq!(counts(doc(&s)), empty);
assert!(doc(&s).pattern("Warped Checks").is_none() && doc(&s).swatch("Warped Checks").is_none() && !filled(doc(&s)));
run(&mut s, "edit.redo", json!({}));
assert!(filled(doc(&s)));
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
assert_eq!(back.pattern("Warped Checks"), Some(&def));
assert_eq!(back.swatch("Warped Checks").map(|w| &w.paint), doc(&s).swatch("Warped Checks").map(|w| &w.paint));
assert!(filled(&back));
}
/// A device name is saved with a `-` after it, which can be another library's own name: neither
/// library replaces the other, and saving either again replaces only itself.
#[test]
fn a_library_named_like_a_device_keeps_the_library_with_its_file_name() {
for (first, second) in [("COM¹", "COM¹-"), ("COM¹-", "COM¹"), ("com¹", "COM¹-")] {
let dir = temp_dir("device-collision");
let mut s = session();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let save =
|s: &mut Session, name: &str| run(s, "swatch.library.save", json!({"user": true, "name": name}))["library"].as_str().unwrap().to_string();
let a = save(&mut s, first);
let b = save(&mut s, second);
assert_ne!(a, b);
assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}");
assert_eq!(save(&mut s, second), b, "saving {second} again replaces {second}");
assert_eq!(s.swatch_libraries.get(&a).unwrap().1.name, first);
assert_eq!(s.swatch_libraries.get(&b).unwrap().1.name, second);
// The name of the numbered file the second one got is a third library's own name.
let third = b.strip_prefix("user/").unwrap().strip_suffix(".vcswatches").unwrap().to_string();
let c = save(&mut s, &third);
assert!(c != a && c != b, "{third}: {c}");
assert_eq!(save(&mut s, &third), c, "saving {third} again replaces {third}");
assert_eq!(s.swatch_libraries.get(&b).unwrap().1.name, second);
let _ = std::fs::remove_dir_all(&dir);
}
}
/// `.ase` keeps no title, so a library's name comes back from its file name. Whichever is saved
/// first, `COM¹` and `COM¹-` keep a file each, and saving either again replaces only itself. After
/// a restart neither can be told from the other, so each gets a new file and replaces neither.
#[test]
fn device_named_libraries_without_a_title_keep_their_own_files() {
for (first, second) in [("COM¹", "COM¹-"), ("COM¹-", "COM¹")] {
let dir = temp_dir("device-ase");
let mut s = session();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let save = |s: &mut Session, name: &str| {
run(s, "swatch.library.save", json!({"user": true, "name": name, "format": "ase"}))["library"].as_str().unwrap().to_string()
};
let a = save(&mut s, first);
assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}");
let b = save(&mut s, second);
assert_ne!(a, b);
assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}");
assert_eq!(save(&mut s, second), b, "saving {second} again replaces {second}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
// The next time the app starts.
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
for (name, count) in [(first, 3), (second, 4)] {
let again = save(&mut s, name);
assert!(again != a && again != b, "{name}: {again}");
assert_eq!(save(&mut s, name), again, "saving {name} again replaces {name}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), count);
}
// The numbered file is as unknown as the first.
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let numbered = b.strip_prefix("user/").unwrap().strip_suffix(".ase").unwrap().to_string();
assert_ne!(save(&mut s, &numbered), b);
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5);
// Any other name goes on replacing its own file after a restart.
let other = save(&mut s, "Greys");
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
assert_eq!(save(&mut s, "Greys"), other);
let _ = std::fs::remove_dir_all(&dir);
}
}
/// A file this session saved for `COM¹`, then replaced, is not written over. The next save goes to
/// a new file and stays there. That holds for `.ase` and `.gpl`, for bytes that still read as a
/// library and for bytes that don't, including the file the library moved to. A file removed and
/// put back is still its library's when the bytes are the ones written, and is kept when they are
/// not. A name that is not a device name still replaces a file that cannot be read.
#[test]
fn a_replaced_device_library_file_is_kept() {
for format in ["ase", "gpl"] {
let other: Vec<u8> = if format == "ase" {
vectorcraft_testkit::ase::ase(&[vectorcraft_testkit::ase::Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)])
} else {
b"GIMP Palette\nName: Other\n0 0 0 Black\n".to_vec()
};
let save = |s: &mut Session| run(s, "swatch.library.save", json!({"user": true, "name": "COM¹", "format": format}));
let open = |name: &str| {
let dir = temp_dir(name);
let mut s = session();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
(dir, s)
};
// Replaced with another library.
let (dir, mut s) = open(&format!("replaced-lib-{format}"));
let first = save(&mut s);
let first_id = first["library"].as_str().unwrap().to_string();
let first_path = first["path"].as_str().unwrap().to_string();
assert_ne!(std::fs::read(&first_path).unwrap(), other);
std::fs::write(&first_path, &other).unwrap();
let moved = save(&mut s);
let moved_id = moved["library"].as_str().unwrap().to_string();
assert_ne!(moved_id, first_id);
assert_eq!(std::fs::read(&first_path).unwrap(), other, "{format}: the replaced file is left as it is");
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()), "{format}: saving again replaces the new file");
let _ = std::fs::remove_dir_all(&dir);
// Replaced with bytes that are not a library, first the file it had, then the one it moved to.
let (dir, mut s) = open(&format!("replaced-unread-{format}"));
let first = save(&mut s);
let first_path = first["path"].as_str().unwrap().to_string();
std::fs::write(&first_path, b"not a library").unwrap();
let moved = save(&mut s);
let moved_id = moved["library"].as_str().unwrap().to_string();
let first_id = first["library"].as_str().unwrap();
assert_ne!(moved_id, first_id);
assert_eq!(std::fs::read(&first_path).unwrap(), b"not a library");
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()), "{format}: saving again stays on the new file");
let moved_path = moved["path"].as_str().unwrap().to_string();
std::fs::write(&moved_path, b"still not").unwrap();
let again = save(&mut s);
let again_id = again["library"].as_str().unwrap().to_string();
assert!(again_id != moved_id && again_id != first_id);
assert_eq!(std::fs::read(&moved_path).unwrap(), b"still not");
assert_eq!(save(&mut s)["library"].as_str(), Some(again_id.as_str()));
let _ = std::fs::remove_dir_all(&dir);
// Removed, the folder listed again, then put back: the hash is checked when it reappears.
let (dir, mut s) = open(&format!("replaced-reappear-{format}"));
let first = save(&mut s);
let first_id = first["library"].as_str().unwrap().to_string();
let first_path = first["path"].as_str().unwrap().to_string();
let written = std::fs::read(&first_path).unwrap();
std::fs::remove_file(&first_path).unwrap();
run(&mut s, "swatch.library.list", json!({}));
std::fs::write(&first_path, &written).unwrap();
assert_eq!(save(&mut s)["library"].as_str(), Some(first_id.as_str()), "{format}: the same bytes are still this library");
std::fs::remove_file(&first_path).unwrap();
run(&mut s, "swatch.library.list", json!({}));
std::fs::write(&first_path, &other).unwrap();
let moved = save(&mut s);
let moved_id = moved["library"].as_str().unwrap().to_string();
assert_ne!(moved_id, first_id);
assert_eq!(std::fs::read(&first_path).unwrap(), other, "{format}: different bytes put back are kept");
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()));
let _ = std::fs::remove_dir_all(&dir);
}
let dir = temp_dir("replaced-ordinary");
let mut s = session();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let first = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Greys", "format": "gpl"}));
let path = first["path"].as_str().unwrap().to_string();
std::fs::write(&path, b"not a library").unwrap();
let again = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Greys", "format": "gpl"}));
assert_eq!(again["library"], first["library"], "a name that is not a device name replaces its own unreadable file");
assert!(std::fs::read(&path).unwrap().starts_with(b"GIMP Palette"));
let _ = std::fs::remove_dir_all(&dir);
}
/// #3602: a library titled with a Windows device name is saved under a name that is not one.
#[test]
fn a_library_file_name_is_never_a_windows_device_name() {
use crate::cmd::swatchlib::library_file_name;
for (title, ext) in [
("CON", "json"),
("nul", "json"),
("Com1", "acb"),
("LPT9", "ase"),
("aux.", "json"),
("CON.txt", "json"),
("nul.tar.gz", "acb"),
("COM¹", "json"),
("lpt³.x", "ase"),
] {
let file = library_file_name(title, ext);
assert!(!crate::file_access::is_device_name(&file), "{title}: {file}");
assert!(
file.ends_with(&format!(".{ext}"))
&& file.to_ascii_lowercase().starts_with(&title.split('.').next().unwrap_or_default().to_ascii_lowercase()),
"{title}: {file}"
);
}
assert_eq!(library_file_name("Console", "json"), "Console.json");
assert_eq!(library_file_name("COM10", "json"), "COM10.json");
}